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-08",
"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. Each event's delivery
headers and full payload schema are documented under
Webhook Events in
the API Reference.
| 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 gained an effective logical entitlement. |
member.entitlement.updated | An effective entitlement's sources or definition changed. |
member.entitlement.revoked | A member lost an effective logical entitlement. |
Use "*" in event_types to subscribe to all of the above, including types
added later.
member.entitlement.granted, .updated, and .revoked come from the
authoritative logical ledger, independently of first-party platform side
effects. They are the recommended events for an operator-controlled access
system. See External entitlements.
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.