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

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 was granted a platform entitlement.
member.entitlement.revokedA 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 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