Webhooks
Subscribe to Arcalotl events, verify signatures, and handle retries.
Webhooks push the same events that back GET /v1/events
to an HTTPS endpoint you control, signed so you can verify they came from
Arcalotl.
Setting up an endpoint
Create an endpoint from the dashboard (Developers -> Webhooks) or through the API:
POST /v1/webhooks/endpoints
Authorization: Bearer arclt_live_...
Content-Type: application/json
{
"url": "https://example.com/webhooks/arcalotl",
"description": "production",
"event_types": ["subscription.active", "subscription.cancelled"]
}Use "event_types": ["*"] to subscribe to every current and future event
type. The response includes the endpoint plus a whsec_... signing secret,
shown in full exactly once. Store it; it cannot be retrieved again, only
rotated with POST /v1/webhooks/endpoints/{id}/rotate-secret.
A community may register up to 5 endpoints. The URL must be https://.
The envelope
Every delivery body, and every item from GET /v1/events, is the same JSON
envelope:
{
"id": "evt_9f2c4b1e5a7d4c0f8b3a2d1e6f5c4b3a",
"type": "subscription.active",
"timestamp": "2026-07-02T12:34:56Z",
"api_version": "2026-07",
"data": { "...the full public resource, plus event-specific fields..." }
}data embeds the full public resource (subscription, purchase, or member
entitlement) as of the moment the event was projected, plus fields specific to
that event type (for example failure_code on subscription.past_due, or
refund on subscription.refunded). Because state can keep changing after an
event fires, treat the envelope as a signal to re-fetch the resource rather
than as the sole source of truth for current state.
Event catalog
GET /v1/webhooks/event-types returns this catalog as JSON. It is available
to any authenticated key, independent of scopes.
| Type | Description |
|---|---|
subscription.created | A subscription was created. |
subscription.active | A subscription became active. |
subscription.renewed | A recurring-cycle invoice was paid for an active subscription. |
subscription.past_due | A subscription payment failed and the subscription is past due. |
subscription.recovered | A past-due subscription recovered after a successful payment. |
subscription.cancelling | A subscription was scheduled to cancel at period end. |
subscription.cancelled | A subscription was fully cancelled. |
subscription.paused | A subscription was paused. |
subscription.resumed | A paused subscription resumed. |
subscription.trial_started | A subscription started a free trial. |
subscription.trial_will_end | A subscription trial is about to end. |
subscription.plan_changed | A subscription's plan changed. |
subscription.refunded | A subscription charge was refunded. |
subscription.disputed | A dispute was opened against a subscription charge. |
subscription.dispute_won | A subscription charge dispute closed in the merchant's favor. |
purchase.completed | A one-time purchase completed. |
purchase.expired | A timed one-time purchase's access expired. |
purchase.refunded | A one-time purchase was refunded. |
purchase.disputed | A dispute was opened against a one-time purchase charge. |
purchase.dispute_won | A one-time purchase dispute closed in the merchant's favor. |
member.entitlement.granted | A member was granted a platform entitlement. |
member.entitlement.revoked | A member lost a platform entitlement. |
Use "*" in event_types to subscribe to all of the above, including types
added later.
member.entitlement.granted / .revoked answer "did this member actually
get or lose access", which is usually what game-server and license-gate
integrations want rather than reconstructing it from subscription and
purchase state.
Verifying signatures
Deliveries follow the Standard Webhooks spec. Each request carries three headers:
webhook-id: evt_9f2c4b1e5a7d4c0f8b3a2d1e6f5c4b3a
webhook-timestamp: 1751459696
webhook-signature: v1,base64signature==The signed content is {webhook-id}.{webhook-timestamp}.{raw body}. The
signature is HMAC-SHA256 over that content, keyed by the bytes you get from
base64-decoding the part of your whsec_ secret after the whsec_ prefix,
then base64-encoded and prefixed v1,. webhook-signature can contain
multiple space-separated v1,... tokens (used during secret rotation); a
match against any one of them is valid. Always verify against the raw request
body, before any JSON parsing, and reject requests whose webhook-timestamp
is more than 5 minutes from your clock to prevent replay.
TypeScript (Node crypto)
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(
rawBody: string,
headers: { "webhook-id": string; "webhook-timestamp": string; "webhook-signature": string },
secret: string, // "whsec_..."
): boolean {
const timestamp = Number(headers["webhook-timestamp"]);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return false; // stale or malformed timestamp
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signedContent = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${rawBody}`;
const expected = createHmac("sha256", key).update(signedContent).digest("base64");
const expectedBytes = Buffer.from(expected);
return headers["webhook-signature"]
.split(" ")
.some((token) => {
const [scheme, value] = token.split(",");
if (scheme !== "v1" || !value) return false;
const candidate = Buffer.from(value);
return candidate.length === expectedBytes.length && timingSafeEqual(candidate, expectedBytes);
});
}Python
import base64
import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
try:
timestamp = int(headers["webhook-timestamp"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > 300:
return False # stale or malformed timestamp
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + raw_body
expected = base64.b64encode(
hmac.new(key, signed_content, hashlib.sha256).digest()
).decode()
for token in headers["webhook-signature"].split(" "):
scheme, _, value = token.partition(",")
if scheme == "v1" and value and hmac.compare_digest(value, expected):
return True
return FalseGo
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"math"
"strconv"
"strings"
"time"
)
func verifyWebhook(rawBody []byte, id, timestamp, signatureHeader, secret string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false // stale or malformed timestamp
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id))
mac.Write([]byte("."))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(rawBody)
expected := []byte(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
for _, token := range strings.Fields(signatureHeader) {
scheme, value, ok := strings.Cut(token, ",")
if !ok || scheme != "v1" {
continue
}
if subtle.ConstantTimeCompare([]byte(value), expected) == 1 {
return true
}
}
return false
}Testing an endpoint
POST /v1/webhooks/endpoints/{id}/test sends a signed ping envelope
({"id": "evt_test_...", "type": "ping", "data": {}}) synchronously and
returns the status code your endpoint responded with, so you can confirm
signature verification end to end before relying on real events. It is
rate-limited to 10 requests/min per endpoint.
Retries, exhaustion, and auto-disable
A delivery counts as successful on any 2xx response. Anything else retries
on a fixed backoff schedule:
5s, 30s, 2m, 10m, 30m, 2h, 5h, 10h, 14h, 24hThat is roughly 10 attempts spread across 2.3 days before a delivery is
marked exhausted. An endpoint that racks up 10 consecutive exhausted
deliveries is automatically disabled (disabled_reason: "sustained_failure");
any successful delivery resets that counter to zero. A disabled endpoint stops
receiving new deliveries until you re-enable it (PATCH .../endpoints/{id}
with "enabled": true).
Use GET /v1/webhooks/endpoints/{id}/deliveries to inspect attempt history
(status, attempt count, last status code, and a response snippet), and
POST .../deliveries/{deliveryId}/redeliver to requeue a specific delivery
for immediate retry once your endpoint is fixed.
Retention
Delivery attempt logs are kept for 30 days. Projected events (what
GET /v1/events reads) are kept for 90 days, longer than deliveries, so you
can reconcile against them even after a delivery log has aged out.