Skip to documentation
Open dashboard
Docs/Server integrations

Outbound webhooks

Verify signatures, handle ordered delivery, rotate secrets, and replay failures.

Download this chapter
On this page

Outbound webhooks notify your backend about supported derived changes. They are distinct from Apple's incoming server notifications and Google's incoming RTDN. In the current implementation, the derived customer-access event is access.changed; a wildcard does not turn every raw store notification into a delivery.

Create an endpoint

In Integrations → Webhooks, add a public HTTPS receiver URL, a description/name, and event filters. Use access.changed for customer-access changes or the supported wildcard when you intend to receive all derived event types.

Copy the endpoint's one-time signing secret and store it in your receiver's secret configuration. The endpoint list does not reveal it again. The equivalent trusted API is POST /v1/webhook-endpoints using the app's secret key; public publishable keys cannot manage endpoints.

Private, loopback, and other special-network targets are rejected, DNS is checked again at connection time, and redirects are not followed. A tunnel/public staging receiver must terminate at a stable HTTPS URL that the service can reach.

Verify before parsing or processing

CherrySub uses Standard Webhooks. Use a maintained verification library with the endpoint secret, the unaltered raw request bytes, and these headers:

HeaderPurpose
webhook-idStable delivery/message identifier across retries and manual replay
webhook-timestampTimestamp for this signed attempt
webhook-signatureHMAC signature(s), including overlap signatures during rotation

Do not stringify a parsed JSON object and use it as the signed body: byte changes break verification. Preserve the raw body before framework middleware consumes it. Validate the timestamp tolerance as well as the signature to protect against replay. The specification defines the interoperable header/signature format.

The envelope has this shape:

json
{
  "id": "evt_123",
  "type": "access.changed",
  "api_version": "2026-08-28",
  "timestamp": "2026-09-09T10:00:00Z",
  "event_number": 123,
  "data": {
    "customerId": "22222222-2222-4222-8222-222222222222",
    "appUserIds": ["user-123"],
    "accessLevelId": "33333333-3333-4333-8333-333333333333",
    "lookupKey": "pro",
    "current": {
      "active": true,
      "expiresAt": "2026-10-09T10:00:00Z",
      "platform": "apple",
      "storeIdentity": "store-lineage-example",
      "productId": "pro.monthly",
      "observedAt": "2026-09-09T10:00:00Z",
      "eventId": 122
    }
  }
}

This is an illustrative access-change payload. previous and current are optional snapshots depending on the transition. The webhook's numeric event_number and nested eventId are int64 values; unlike public receipt/subscriber event_id strings, JavaScript receivers must handle them without unsafe numeric rounding. Use the stable string IDs for idempotency rather than converting a rounded number back into an ID.

Make receiver processing idempotent

  1. Verify the raw body and headers.
  2. Atomically record the accepted webhook-id (or enqueue it with a uniqueness constraint) with your processing decision.
  3. Return 2xx after durable acceptance. Repeated delivery of the same ID should also receive 2xx without repeating side effects.
  4. Process expensive work asynchronously and make downstream side effects idempotent too.

A receipt may already have updated access before your receiver processes the webhook. For a current authorization decision, read the subscriber API. Expiry is evaluated at read time; there is no guarantee of a webhook arriving at the exact expiry instant without a new source observation.

Go receiver example

This handler verifies the timestamp and signature before handing raw bytes to an application-owned durable accept function. The accept function must atomically deduplicate by message ID and enqueue/process the event; returning nil for an already accepted ID is correct.

go
package receiver

import (
    "context"
    "errors"
    "io"
    "net/http"

    standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)

func Handler(
    secret string,
    accept func(context.Context, string, []byte) error,
) (http.Handler, error) {
    if accept == nil {
        return nil, errors.New("durable accept function is required")
    }
    verifier, err := standardwebhooks.NewWebhook(secret)
    if err != nil {
        return nil, err
    }
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            w.Header().Set("Allow", http.MethodPost)
            w.WriteHeader(http.StatusMethodNotAllowed)
            return
        }
        raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 20 << 10))
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        if err := verifier.Verify(raw, r.Header); err != nil {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        if err := accept(r.Context(), r.Header.Get("webhook-id"), raw); err != nil {
            w.WriteHeader(http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusNoContent)
    }), nil
}

Use the secret returned by endpoint creation/rotation, a bounded receiver timeout, and an authenticated deployment configuration. Do not replace Verify with a timestamp-ignoring test helper in a live receiver. Keep int64 JSON values lossless when the accept function decodes the envelope.

Ordering and retries

Deliveries are head-of-line ordered per endpoint. A failing endpoint delays its later deliveries, while other endpoints can continue. Event numbers can have gaps; do not wait forever for the missing integer.

Only 2xx succeeds. Retry delays begin at 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours, then use 24-hour intervals within the final 72-hour retry window. Retry-After can extend a delay. HTTP 410 or exhaustion dead-letters the delivery and disables the endpoint.

The body and webhook-id remain stable; timestamps and signatures are regenerated for each attempt. Endpoint status/delivery status values include pending, in flight, retry, succeeded, dead letter, and cancelled. A disabled endpoint is not proof its queued history was processed successfully.

Inspect and replay

Open the Deliveries tab, filter by status, and inspect an attempt's HTTP status, timing, safe error, and bounded response. Fix the receiver or credentials first. Use the explicit retry/replay action when appropriate, and enable the endpoint if its failure disabled it.

Replay keeps the same message ID, so your receiver's deduplication must continue to work. Deleting/disabling an endpoint does not delete the immutable source evidence. The API reference lists endpoint lifecycle and delivery-history operations.

Rotate the secret

Rotate from the endpoint's action, copy the new one-time secret, update the receiver, and verify delivery. Rotation dual-signs with old/new keys for 24 hours. Coordinate the overlap instead of rejecting the old secret immediately during rollout.

Self-hosted deployments must retain encryption-key coverage for all still-live signing material. Missing key coverage can block startup; losing the encryption key is not repaired by reading a safe secret hint. Security · Operations.