ArcalotlArcalotl

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.

TypeDescription
subscription.createdA subscription was created.
subscription.activeA subscription became active.
subscription.renewedA recurring-cycle invoice was paid for an active subscription.
subscription.past_dueA subscription payment failed and the subscription is past due.
subscription.recoveredA past-due subscription recovered after a successful payment.
subscription.cancellingA subscription was scheduled to cancel at period end.
subscription.cancelledA subscription was fully cancelled.
subscription.pausedA subscription was paused.
subscription.resumedA paused subscription resumed.
subscription.trial_startedA subscription started a free trial.
subscription.trial_will_endA subscription trial is about to end.
subscription.plan_changedA subscription's plan changed.
subscription.refundedA subscription charge was refunded.
subscription.disputedA dispute was opened against a subscription charge.
subscription.dispute_wonA subscription charge dispute closed in the merchant's favor.
purchase.completedA one-time purchase completed.
purchase.expiredA timed one-time purchase's access expired.
purchase.refundedA one-time purchase was refunded.
purchase.disputedA dispute was opened against a one-time purchase charge.
purchase.dispute_wonA one-time purchase dispute closed in the merchant's favor.
member.entitlement.grantedA member gained an effective logical entitlement.
member.entitlement.updatedAn effective entitlement's sources or definition changed.
member.entitlement.revokedA 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 False

Go

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, 24h

That 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.

On this page