Webhooks
Reference for the outbound webhook delivery Attesto sends to your callback URL. For receiver implementations, see the backend recipes — every recipe (Deno, Node, Python, Java, Ruby) includes a working webhook receiver with HMAC verification, replay-window guard, and idempotency notes.
Outbound delivery format
Headers Attesto sets on every delivery:
| Header | Example | Meaning |
|---|---|---|
X-Attesto-Event | subscription.renewed | Unified event name — same value as the body's event |
X-Attesto-Event-Id | evt_01HX... | Attesto-internal event ULID |
X-Attesto-Timestamp | 1744464130 | Unix seconds at sign time |
X-Attesto-Signature | t=1744464130,v1=<hex-hmac-sha256> | Signature over <ts>.<body> |
X-Attesto-Version | v0.0.24 | Build of Attesto that sent the delivery (dev for an un-tagged build). Informational — a debugging breadcrumb, not a contract version. The payload shape is stable; do not branch on it. (It is not part of the signed body — it's a header only, like the others above.) |
Body (JSON):
{
"event": "subscription.renewed",
"reason": null,
"platformEvent": "apple.did_renew",
"eventId": "evt_01HX...",
"externalId": "<apple notificationUUID or google messageId>",
"timestamp": "2026-04-18T12:00:00.000Z",
"tenantId": "tenant_01HX...",
"source": "apple",
"subject": {
"key": "2000000123456789",
"productId": "com.example.premium.monthly",
"type": "subscription"
},
"appUserId": null,
"data": {/* normalized event payload */},
"raw": {/* original decoded payload from Apple/Google */}
}TypeScript interface (copy-paste into your handler):
interface AttestoWebhookPayload {
/** Unified event name, e.g. "subscription.renewed" — see § Event types */
event: string;
/**
* Sub-classification when the upstream payload carries one (Apple
* subtypes — e.g. "voluntary" / "billing_retry" for an expiry).
* NULL when the upstream is undifferentiated (Google's flat numeric
* codes) or no subtype applies.
*/
reason: string | null;
/**
* Original upstream identifier in Attesto's pre-unification form —
* `apple.<type>{.<subtype>}` / `google.subscription.<N>` etc.
* Preserved for debugging, advanced routing, and audit logs.
*/
platformEvent: string;
/** Internal event id (evt_<ULID>) — primary idempotency key */
eventId: string;
/** Original Apple notificationUUID / Google messageId */
externalId: string;
/** ISO-8601 receive time */
timestamp: string;
/** Attesto tenant id (tenant_<ULID>) */
tenantId: string;
source: "apple" | "google";
/**
* Purchase identity. NULL for events without a transaction
* (Apple TEST, Google testNotification, refund). See § subject.
*/
subject: WebhookEventSubject | null;
/**
* App-supplied user identity (UUID v4). NULL when the original
* purchase did not carry one. See § appUserId.
*/
appUserId: string | null;
/** Normalized event payload — recommended consumption surface */
data: Record<string, unknown>;
/** Original decoded payload from Apple/Google — for power users */
raw: Record<string, unknown>;
}
interface WebhookEventSubject {
/** Apple `originalTransactionId` / Google root `purchaseToken` */
key: string;
productId: string | null;
type: "subscription" | "product";
}subject
The unified mapping key for backend user-association. Save subject.key at first verify against your (platform, key) → userId table; look it up here when the webhook fires. Eliminates the need to decode Apple's inner JWS or case-split between Google's subscriptionNotification / oneTimeProductNotification to find the stable identifier.
| Field | Type | Apple source | Google source |
|---|---|---|---|
key | string | signedTransactionInfo.originalTransactionId | subscriptionNotification.purchaseToken (chain-resolved — see below) / oneTimeProductNotification.purchaseToken |
productId | string | null | signedTransactionInfo.productId | subscriptionNotification.subscriptionId / oneTimeProductNotification.sku |
type | "subscription" / "product" | derived from signedTransactionInfo.type | subscriptionNotification → subscription; oneTimeProductNotification → product |
Google subscription chain resolution. When a user moves between SKUs in the same subscription group, Google issues a new purchaseToken linked to the previous one via linkedPurchaseToken. Attesto fetches the full SubscriptionPurchaseV2 from Play API on every Google subscription webhook, records the link, and walks back to the root token before persisting. The subject.key on the outbound payload is therefore always the integrator's first-seen original token, even after multiple upgrades — no fallback logic on the integrator's side. Apple is unaffected because Apple's originalTransactionId is already stable across renewals.
subject is null for events without a transaction:
- Apple
TESTnotifications (App Store Connect's Request a Test Notification button or the equivalent App Store Server API call) - Google
testNotificationenvelopes (Play Console's Send test notification) - Google
voidedPurchaseNotification(refund —orderIdbased, no token field on the upstream payload) - Malformed / unrecognized shapes (Attesto logs and falls through)
This means probe tests don't exercise the user-mapping path. Probe tests prove your webhook URL + HMAC verification work; only a real sandbox purchase (see Integration guide § Step 5) delivers a populated subject and exercises your (platform, key) → userId lookup.
Backend handlers should treat subject == null as "ignore for user-mapping purposes" — the event is still real (eventId / event / data are populated), but it doesn't tie to a single user record.
appUserId
The app-supplied UUID attached at purchase time. Lets backends join directly on user identity without going through subject.key.
| Type | Apple source | Google source |
|---|---|---|
string | null | signedTransactionInfo.appAccountToken (inner JWS) | externalAccountIdentifiers.obfuscatedExternalAccountId (Play API response — fetched at receive time) |
Always present in the envelope; null when the original purchase didn't carry one (guest flows, pre-existing transactions, SDKs that don't expose appAccountToken / obfuscatedAccountId). Backends should use appUserId as the primary join key when set, falling back to subject.key upsert when null. See the integration guide § mapping webhook events back to users for the full pattern.
For Google, Attesto's webhook receiver fetches the SubscriptionPurchaseV2 once per inbound subscription notification (the same call that resolves the linkedPurchaseToken chain — no extra Play API quota burned). For Google one-time products, voided purchases, and test notifications appUserId is always null because the inbound notification doesn't carry external identifiers and we don't fetch the Play API for those event types.
Why is appUserId a top-level field and not nested under subject?
subject is purchase identity — which subscription / product is this event about? appUserId is user identity — which user of your app does the purchase belong to? The two are orthogonal: a single user has many subscriptions, and Apple's family-sharing splits the relationship further (one purchase, multiple users via inAppOwnershipType). Nesting appUserId under subject would imply the user is a property of the purchase — but it's the inverse: the purchase belongs to the user. Keep them separate when you store and query.
data and raw
The data field is the cleaned-up payload Attesto recommends consuming. The raw field is the original decoded JWS / Pub/Sub envelope, included so power users can read fields Attesto doesn't surface in data or subject.
Signature verification
The X-Attesto-Signature header has format t=<unix_seconds>,v1=<hex_hmac_sha256>. The HMAC is computed over ${t}.${rawBody} with your tenant's webhook secret as the key. To verify:
- Parse
tandv1from the header. - Reject if
|now - t| > 300(5-minute replay window). - Compute
HMAC-SHA256(secret, "${t}.${rawBody}")over the raw bytes Attesto sent (not a JSON-parsed-then-restringified version). - Compare with
v1in constant time (crypto.timingSafeEqual/hmac.compare_digest/MessageDigest.isEqual/OpenSSLconstant-time helpers).
Working implementations in 5 languages: see the backend recipes.
Retry schedule
If your callback returns anything non-2xx (or doesn't respond within 10 seconds), Attesto retries with this schedule:
| Attempt | Delay since previous | Cumulative |
|---|---|---|
| 1 | immediate | 0 |
| 2 | 30 seconds | 30s |
| 3 | 2 minutes | 2m30s |
| 4 | 10 minutes | 12m30s |
| 5 | 1 hour | 1h12m |
| 6 | 6 hours | 7h12m |
After 6 failed attempts (~7h12m), Attesto marks the delivery failed and stops retrying.
Idempotency
Attesto dedupes inbound events on the upstream's idempotency key (Apple's notificationUUID, Google's Pub/Sub messageId), so each underlying upstream event produces exactly one logical delivery from Attesto.
Your callback should also be idempotent on X-Attesto-Event-Id. A delivery may be retried multiple times if your callback returned 5xx on attempt 1 but the side effect (e.g. updating a subscription state) had already happened. Treat the same eventId as the same logical operation — persist it in a processed_events(event_id text primary key, processed_at timestamptz) table on your side and check before doing anything destructive.
Event types
Attesto delivers events in a unified, platform-agnostic vocabulary so backend handlers write one switch statement that covers both Apple and Google. Three fields drive event handling:
event— the unified noun-verb name (subscription.renewed,subscription.refunded, etc.). Switch on this.reason— finer-grained intent when the upstream payload carries a subtype (Apple'sEXPIRED.VOLUNTARYvsEXPIRED.BILLING_RETRY).nullwhen the upstream is undifferentiated or no subtype applies. Use it to choose UX response without branching onsource.platformEvent— the original upstream identifier (apple.did_renew/google.subscription.2). Preserved for debugging, advanced routing, and audit logs.
The full mapping table lives in the source — app/services/webhooks/normalize.ts. The reference below is organized by tier of importance so you can prioritize implementation:
- Tier 1 — fire in normal subscription operation. Implement these.
- Tier 2 — real flows you'll hit in production. Recommended.
- Tier 3 — feature-specific (price changes, promotional offers, EU DMA external purchases). Implement only if your app uses the relevant feature.
- Tier 4 — passthrough (test probes, future events Attesto doesn't yet recognize).
Tier 1 — must handle
subscription.purchased
A user just bought a subscription for the first time, or resubscribed after a prior expiry. Grant the entitlement. (Note: most apps already grant on the verify endpoint response; the webhook is a backup signal in case verify was missed or the user purchased outside your app.)
reason values:
initial— first-ever purchase of this subscription group for this account.resubscribe— purchased again after a prior subscription expired.null— Google'sSUBSCRIPTION_PURCHASED (4)doesn't distinguish between initial and resubscribe; Attesto reportsreason: "initial"for it as the closest match (see Google'slinkedPurchaseTokenchain to detect resubscribe on Google).
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | SUBSCRIBED (subtypes: INITIAL_BUY, RESUBSCRIBE) |
SUBSCRIPTION_PURCHASED (4) |
subscription.renewed
The subscription auto-renewed normally (no billing problem). Extend expiresAt to the new period end.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | DID_RENEW (no subtype) |
SUBSCRIPTION_RENEWED (2) |
subscription.recovered
The subscription auto-renewed after a billing-retry period (Apple's BILLING_RECOVERY / Google's SUBSCRIPTION_RECOVERED). Extend expiresAt and clear any "in retry" flag. Optionally surface a "thanks, your payment went through" UI.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | DID_RENEW.BILLING_RECOVERY |
SUBSCRIPTION_RECOVERED (1) |
subscription.cancellation_scheduled
The user turned off auto-renew but their period hasn't ended yet — they still have access until subject.productId's expiresAt. Do not revoke entitlement. Mark the subscription as "ending at expiresAt" so your UI can show "your subscription ends on …" and avoid trying to renew on your side.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | DID_CHANGE_RENEWAL_STATUS.AUTO_RENEW_DISABLED |
SUBSCRIPTION_CANCELED (3) |
subscription.cancellation_revoked
The user re-enabled auto-renew (Apple) or restarted a previously canceled subscription (Google). Clear the "ending" flag.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | DID_CHANGE_RENEWAL_STATUS.AUTO_RENEW_ENABLED |
SUBSCRIPTION_RESTARTED (7) |
subscription.expired
The subscription's billing period ended and the user no longer has access. Revoke the entitlement. Use reason to choose UX response — show "resubscribe" for a voluntary expiry, "update payment method" for billing- retry exhaustion.
reason values:
voluntary— user turned off auto-renew and the period ran out.billing_retry— Apple's billing retry exhausted; subscription was force-terminated.product_not_for_sale— the product was removed from sale before the renewal window.null— Google'sSUBSCRIPTION_EXPIRED (13)doesn't carry a subtype; the reason is unavailable from the upstream payload.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | EXPIRED (subtypes: VOLUNTARY, BILLING_RETRY, PRODUCT_NOT_FOR_SALE) |
SUBSCRIPTION_EXPIRED (13) |
subscription.revoked
The platform forcibly revoked the subscription — different from a user- initiated cancel. Apple fires REVOKE when a family-shared subscription loses access (the original purchaser canceled, refund cleared, etc.). Google fires SUBSCRIPTION_REVOKED for fraud / refund / family-sharing revocations. Revoke the entitlement immediately.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | REVOKE (Family Sharing access lost) |
SUBSCRIPTION_REVOKED (12) |
subscription.refunded
The user was refunded — by Apple (App Store Connect refund decision) or by Google (Play Store refund or chargeback). Revoke the entitlement and reverse any provisioned content. Note: subject is null for google.voided and (per Apple's payload shape) for some Apple REFUND events; backends look up the user via data.signedTransactionInfo.transactionId (Apple) or raw.voidedPurchaseNotification.purchaseToken / .orderId (Google) directly.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | REFUND |
voidedPurchaseNotification |
subscription.in_grace_period
A renewal failed but the platform is giving the user grace-period access (typically 30 days) while it retries the payment. Keep the entitlement active during grace; optionally surface an "update payment method" prompt in your app.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | DID_FAIL_TO_RENEW.GRACE_PERIOD |
SUBSCRIPTION_IN_GRACE_PERIOD (6) |
subscription.in_billing_retry
A renewal failed and there's no grace period configured — the platform is silently retrying. Apple-only event (Google rolls billing retry into grace period). Optionally mark "in retry" without revoking; do not revoke until subscription.expired (with reason: "billing_retry").
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | DID_FAIL_TO_RENEW (no subtype) |
| — |
subscription.grace_period_expired
The grace period elapsed without a successful retry. Revoke the entitlement (or wait for subscription.expired — both fire in close succession).
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | GRACE_PERIOD_EXPIRED |
| — |
subscription.on_hold
Google-only. Billing retry exceeded the grace period — user is on hold, no service. Revoke entitlement.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | — |
SUBSCRIPTION_ON_HOLD (5) |
Tier 2 — recommended
subscription.upgraded / subscription.downgraded
Plan tier changed. Apple fires SUBSCRIBED with UPGRADE / DOWNGRADE subtypes. Google fires SUBSCRIPTION_PURCHASED (4) with a linkedPurchaseToken chain to the previous SKU — Attesto resolves the chain server-side and surfaces the upgrade as subscription.purchased with subject.key pointing at the original token; integrators that want explicit upgrade events can branch on subject.productId change against their stored history.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | SUBSCRIBED.UPGRADE / SUBSCRIBED.DOWNGRADE |
(derived from linkedPurchaseToken chain on a SUBSCRIPTION_PURCHASED (4)) |
subscription.renewal_pref_changed
The user scheduled a plan change for the next renewal (Apple-only explicit signal). Stage the change in your UI; don't apply until the next renewal.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | DID_CHANGE_RENEWAL_PREF.AUTO_RENEW_PREF_CHANGE |
| — |
subscription.paused / subscription.pause_schedule_changed
Google-only. Apps that allow users to pause subscriptions get these notifications. Revoke the entitlement on paused; track schedule changes for your billing logic.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | — |
SUBSCRIPTION_PAUSED (10) / SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11) |
subscription.deferred
Google-only. The developer programmatically deferred the next billing date via the Play Developer API. Update your stored expiresAt to the new deferred date.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | — |
SUBSCRIPTION_DEFERRED (9) |
subscription.refund_declined / subscription.refund_reversed
Apple-only. The first signals Apple denied a refund request; the second that an earlier refund was undone. Re-grant the entitlement on refund_reversed; log only on refund_declined.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | REFUND_DECLINED / REFUND_REVERSED |
| — |
Tier 3 — feature-specific
subscription.price_change_pending / subscription.price_change_accepted / subscription.price_change_updated / subscription.price_change_rejected
Pricing change lifecycle. Apple fires PRICE_INCREASE with PENDING / ACCEPTED subtypes; Google fires three distinct integers (SUBSCRIPTION_PRICE_CHANGE_CONFIRMED (8), SUBSCRIPTION_PRICE_CHANGE_UPDATED (19), SUBSCRIPTION_PRICE_CHANGE_REJECTED (20)). Required for apps that raise prices — Apple may require user consent in some regions.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | PRICE_INCREASE.PENDING / PRICE_INCREASE.ACCEPTED |
SUBSCRIPTION_PRICE_CHANGE_CONFIRMED (8) / 19 / 20 |
subscription.offer_redeemed
Apple-only. The user redeemed a promotional offer (intro / win-back / promotional). Update analytics and any tier-specific entitlement state.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | OFFER_REDEEMED |
| — |
subscription.renewal_extended / subscription.renewal_extension_complete / subscription.renewal_extension_failed
Apple-only. Fired when you call Apple's Renewal Extension API to extend subscriptions for customer-success / refund-mitigation purposes.
subscription.renewal_extended— an individual renewal extension succeeded. Update your storedexpiresAt.subscription.renewal_extension_complete— a mass (batch) renewal extension succeeded (AppleSUMMARYsubtype).subscription.renewal_extension_failed— a mass renewal extension failed (AppleFAILUREsubtype). Operator may need to retry the API call or notify customer success; no per-user state change.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | RENEWAL_EXTENDED (individual) / RENEWAL_EXTENSION.SUMMARY (mass success) / RENEWAL_EXTENSION.FAILURE (mass failure) |
| — |
subscription.consumption_request
Apple-only. Apple requests consumption information when a user asks for a refund (typically for consumables, sometimes for subscriptions in contested-refund flows). You have 12 hours to respond via Apple's Send Consumption Information endpoint with structured data (lifetime dollars spent, consumption status, etc.). Attesto does not yet wrap this endpoint — call Apple directly using the same App Store Server API key you've configured.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | CONSUMPTION_REQUEST |
| — |
subscription.external_purchase_token
Apple-only. EU DMA-only event for apps using external purchase entitlement. Most apps don't need this.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | EXTERNAL_PURCHASE_TOKEN |
| — |
subscription.pending_purchase_canceled
Google-only. A pending-payment purchase (e.g. cash payment via Google Pay deferred) was canceled before completion. No entitlement change needed since none was granted.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | — |
SUBSCRIPTION_PENDING_PURCHASE_CANCELED (17) |
product.purchased / product.canceled / product.charged
One-time / consumable product events. product.purchased and product.canceled are Google one-time-product notifications; product.charged is Apple's ONE_TIME_CHARGE. Provision / revoke the non-subscription entitlement accordingly.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | ONE_TIME_CHARGE → product.charged |
oneTimeProductNotification.notificationType: 1 → product.purchased / 2 → product.canceled |
Tier 4 — passthrough
test
Apple's Request a Test Notification probe or Google Play Console's Send test notification. Acknowledge with HTTP 200; no business logic. subject is null — these don't carry a real transaction.
Platform sources:
| Platform | Upstream event |
|---|---|
| Apple | TEST |
testNotification |
unknown
Reserved fallback. When Apple or Google ships a new notification type before Attesto's mapping table is updated, deliveries still arrive with event: "unknown" and the upstream identifier preserved on platformEvent. Backends should default-case this, log platformEvent for triage, and ignore the event for state changes.
The presence of unknown events in your logs is a signal to check for an Attesto update.
Testing the webhook setup
Two admin commands exercise the webhook pipeline, from opposite ends:
| Command | Tests | Style | Use when |
|---|---|---|---|
t:wh:probe (webhook:probe) | Apple/Google → Attesto → backend (the whole chain) | async — fires upstream, returns | confirming Apple S2S / Google Pub/Sub → Attesto plumbing works |
t:wh:ping (webhook:ping) | Attesto → backend (the delivery leg only) | synchronous — reports the HTTP result | confirming the backend's callback URL is reachable + verifies the signature + 2xx |
They're complementary: probe can't be faked (you can't forge an Apple JWS or a Google OIDC push), so it's the only way to test the upstream half; ping gives an immediate answer for the downstream half without involving Apple/Google at all.
t:wh:probe — full chain (async)
After deploying Attesto for a new tenant — or whenever a webhook URL, HMAC secret, Pub/Sub topic, or Apple S2S URL changes — fire a real test event through the chain and confirm it reaches the backend handler.
run t:wh:probe tenant_01HX... # auto: probes whichever creds exist
run t:wh:probe tenant_01HX... --platform apple # only Apple
run t:wh:probe tenant_01HX... --platform google # only Google (requires pubsub_topic)What it does:
- Apple — calls
POST /inApps/v1/notifications/testsigned with the tenant's stored App Store Connect key. Apple dispatches anotificationType: TESTto the configured webhook URL. Attesto normalizes it toevent: "test"(platformEvent: "apple.test") and delivers to the backend. - Google — publishes a synthetic
testNotificationpayload directly to the tenant's configured Pub/Sub topic via the Pub/Sub publish REST API. Google delivers via the push subscription, OIDC verification runs, and Attesto normalizes toevent: "test"(platformEvent: "google.test") and delivers to the backend.
One-time setup for the Google side:
- Store the topic on the tenant:
cli google:set-credentials tenant_01HX... \ --package-name com.example.app \ --service-account-path /path/to/sa.json \ --pubsub-topic projects/<project>/topics/<name> - Grant
roles/pubsub.publisherto the same service account on the topic. Without it, the probe returns a 403 with an explicit IAM error pointing at the missing role.
Asking the backend to confirm. Wire your backend's webhook handler to log every event (e.g. console.log(payload.event, payload.source)). After running the probe, you should see two log lines:
test apple
test googleIf only one shows up, the chain is broken on whichever platform is missing — check the corresponding platform's setup (Apple S2S URL in App Store Connect / Google push-subscription endpoint and audience in GCP).
Read payload.source directly The unified payload exposes
source: "apple" | "google" — read it directly rather than parsing platformEvent. platformEvent (e.g. apple.did_renew) is preserved for audit / debugging, but source is the canonical platform identifier. :::
t:wh:ping — delivery leg only (synchronous)
webhook:ping skips Apple/Google entirely. Attesto builds a synthetic test payload, signs it with the tenant's HMAC secret, POSTs it directly to the configured callback URL, and reports the HTTP status + round-trip latency — right away. Use it to confirm "the backend's webhook endpoint is up, verifies the signature, and returns 2xx" without waiting on an upstream event.
run t:wh:ping tenant_01HX... # pretty output
run t:wh:ping tenant_01HX... --format json # single JSON line, for pipingOutput:
POST https://backend.example.com/webhooks/attesto
→ 200 OK in 142ms
✓ backend accepted the test delivery→ 401 (or any non-2xx) means the backend got the request but rejected it — usually the HMAC secret on the backend doesn't match what was configured via webhook:set-config. ✗ connection failed means the URL isn't reachable from the public internet (DNS, firewall, wrong port, TLS).
The ping payload carries event: "test" (so a "default-case → ack 200" handler accepts it just like a real Apple/Google test) and platformEvent: "attesto.ping" (so it's distinguishable in backend logs from apple.test / google.test that t:wh:probe produces). subject is null, data is { "ping": true }, raw is {}. source is set to "apple" as a placeholder (the field's type is "apple" | "google"); don't branch on source for event: "test" payloads. The request carries the same headers a real delivery does (X-Attesto-Signature, X-Attesto-Event, X-Attesto-Version, …). No webhook_events or webhook_deliveries rows are written — ping is side-effect-free; it won't show up in t:wh:deliveries.
Exit codes: 0 = 2xx, 1 = non-2xx or connection error, 2 = no webhook config / config disabled / bad args.
See also
- Backend recipes — runnable receiver implementations
- Integration guide § Receive webhooks — narrative integration walkthrough
- API reference — verify endpoint specifications
- Self-host webhooks setup — operator-side: registering Apple S2S / Google Pub/Sub URLs