Skip to main content

Overview

Webhooks allow you to receive real-time HTTP notifications when events occur in your AppDNA application. When a subscribed event fires, AppDNA sends a POST request to your configured endpoint with a JSON payload describing the event.

Supported Event Types

AppDNA supports 16 webhook event types:

Payload Format

Every webhook delivery sends a JSON payload with the following structure:

Payment completed data

A store-driven webhook is recorded in the same database write as the store notification it belongs to, and handed to webhook delivery once that write has committed: on the App Store before AppDNA’s other follow-up work (the entitlement sync and the internal billing event), on Google Play after the entitlement sync and before the internal events. It is marked sent only when its delivery records are stored, so it is not lost when the hand-off or the work after it fails: the store redelivers the notification and a webhook still pending is handed off then. This is at-least-once: if the hand-off succeeded but marking it sent did not, the redelivery hands it off again, as a new delivery with a new id — so your endpoint can, rarely, see the same store event twice (for payment.completed, the same data.transaction_id), never none — unless it is still undelivered 24 hours after it was recorded, or every allowed attempt was started and the last one never finished, in which case it is marked failed (see Retry Policy) and can be retried by hand. Purchases that reach AppDNA only through an inbound provider webhook (RevenueCat, Adapty and similar) send no payment.completed.
Changed in the September 2026 server release. payment.completed differs from the previous release in its payload and in which charges send it, and payment.failed and subscription.canceled changed too.payment.completed payload:
  • data.transaction_id was the AppDNA subscription id, the same for every renewal. It is now the store’s transaction id: the App Store transactionId, or the Google Play order id.
  • data.subscription_id is new: the AppDNA subscription id that transaction_id used to carry.
  • data.price is null when no price is known. It was 0.
  • data.currency is null when price is null or the store reported no currency. It was USD when no currency was recorded.
  • For an App Store transaction reported after a later one, product_id, price and currency are that transaction’s own. They were the subscription’s current ones.
Now sent: an App Store resubscribe, an App Store paid offer that starts, restarts or immediately upgrades a subscription, and a Google Play subscription recovering from account hold.No longer sent: a free-trial start, a scheduled App Store downgrade (or any App Store renewal-preference change other than an immediate upgrade), an App Store transaction whose price is 0, a Family Sharing member’s access, a Google Play charge notification processed after Play has moved the subscription to grace period, account hold, paused or expired, and a Google Play renewal or purchase notification processed while the subscription is revoked or refunded in AppDNA.Google Play:
  • A notification whose purchase token Google Play answers with 400, 404 or 410 now sends nothing. It used to be processed from the notification data alone, and could send a webhook only when AppDNA already had the subscription (or the notification was a new purchase for a package name with an active Google Play credential).
  • A notification for a purchase token that belongs to another app, or one AppDNA cannot attribute to a single app (no ?app= on the push endpoint and several apps share the package name), now sends nothing.
  • What a notification answers now depends on the app’s Google Play credential. In each of these cases it used to be processed from the notification data alone, and could send a webhook only when AppDNA already had the subscription (or the notification was a new purchase for a package name with an active Google Play credential):
    • The app has a Google Play credential, but its service-account JSON is missing, invalid or incomplete: 5xx, so Pub/Sub keeps redelivering the notification until the credential is fixed.
    • The push endpoint carries ?app= and that app has no active Google Play credential: 404.
    • The push endpoint has no ?app= and no app’s active Google Play credential has the notification’s package name: 200, and the notification is dropped.
  • payment.completed product_id follows the product of Google Play’s current line item. It used to stay the product already on the subscription: the one the app last verified, or, for a subscription first seen through a Play purchase notification, that notification’s subscriptionId. A Play notification never changed it.
payment.failed:
  • It is no longer sent for an App Store notification about a transaction older than the one the subscription already carries.
  • A Google Play SUBSCRIPTION_REVOKED sends it only when Play confirms the subscription is expired. It was sent for every revoke.
subscription.canceled:
  • It is now sent for the App Store too: DID_CHANGE_RENEWAL_STATUS / AUTO_RENEW_DISABLED. It was Google Play only.
  • It is sent only when Play’s state agrees: canceled, expired, or active with auto-renew off. It was sent for every SUBSCRIPTION_CANCELED.
  • data.cancel_reason can now be canceled. It was always voluntary.
  • data.effective_date is kept from when AppDNA first recorded auto-renew as off. It was the time the notification was processed.
All three billing webhooks now carry data.environment (production or sandbox). A Sandbox App Store purchase used to send payment.completed with nothing telling it apart from a production one.To detect a redelivered webhook, use the event id, which stays the same on every retry of a delivery.

Request Headers

Every webhook request includes the following headers:

Signature Verification

Every webhook request is signed with your endpoint secret using HMAC-SHA256. Always verify the signature before processing the payload. The signature is sent in the X-AppDNA-Signature header in the format:
The signing input is the raw JSON stringified request body.
Always use constant-time comparison (e.g., crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python) when verifying signatures. Simple string comparison is vulnerable to timing attacks.

Retry Policy

If your endpoint returns a non-2xx response or times out, AppDNA retries the delivery with exponential backoff: Maximum retries: 5 attempts total (1 initial delivery + 4 retries). A delivery whose first attempt never started (the AppDNA process that recorded it stopped before sending it) is sent by the retry schedule once it has waited 5 minutes, and that send is its first attempt; if the process stopped in the middle of the first send, the retry schedule’s send is its second. The attempts toward the 5 are counted like this: the first when the delivery is recorded, each retry when it is scheduled after a failed attempt, and a send that the sending process stopped in the middle of when the retry schedule picks the delivery up again — so such a send still counts, and no delivery is ever sent more than 5 times: once all 5 have started and the last one never finished, the delivery is marked failed. One AppDNA process sends a given attempt: it re-confirms that it still holds the delivery just before sending and records the result only while it still does, so two overlapping retry runs never send the same delivery twice. A test delivery (sent once, never retried) whose one send recorded no answer is marked failed after 5 minutes. The delivery log shows the attempt the delivery is on, or waiting for, as n/5 (a test delivery, sent once and never retried, as 1/1). One that is still pending or waiting to retry 24 hours after it was recorded or last retried by hand is marked failed too, instead of being delivered late. Either way the reason is in its delivery log, and it says which case it was: never sent (Never sent: …), attempted and still waiting to retry (Given up: attempted, and still waiting …), attempted with the last attempt never finished, so there is no answer to show (Given up: attempted, and the last attempt never finished …), or out of attempts (Given up: every allowed attempt was started, … or Given up: no attempt is left, …); retry it by hand to send it (that starts a new 5 attempts and a new 24 hours). Delivery timeout: 30 seconds, for the request and reading the response body. If your server does not respond within 30 seconds, the delivery is marked as failed and scheduled for retry; a response whose status arrived in time but whose body did not finish is judged by its status, with an empty body in the delivery log. Maximum response body logged: 10 KB. Response bodies exceeding this limit are truncated in delivery logs.
After 50 consecutive delivery failures, the webhook endpoint is automatically disabled. You can re-enable it from the dashboard or via the API.

Management API

All management endpoints require Customer JWT authentication (Authorization: Bearer header).

List Endpoints

Returns all configured webhook endpoints for the current application. The signing secret is never returned (only once, by create and rotate-secret), and every custom header value is shown as ••••••; header names are kept.

Create Endpoint

Request body:
Webhook URLs must use HTTPS: an http:// URL is rejected with 400. The host must resolve to a public internet address: a URL with credentials in it, a private, loopback or link-local address, an internal host name, or a host that does not resolve is refused when you create the endpoint, with 422 and error.code WEBHOOK_URL_NOT_PUBLIC, and the message names the reason. The same check runs again before every delivery, since a host’s DNS can change after you register it; a delivery it refuses is recorded as failed. AppDNA does not follow redirects: a 3xx response counts as a failed delivery.

Update Endpoint

Update any field on an existing endpoint. Partial updates are supported. headers replaces the stored set: a header whose value is •••••• (as the API returns it) keeps its stored value, a new value replaces it, and a header left out is removed; •••••• for a header that has no stored value is rejected with 400. A new url is checked as on create (422 WEBHOOK_URL_NOT_PUBLIC when it does not resolve to a public HTTPS address); an update that leaves the URL unchanged is not re-checked.

Delete Endpoint

Permanently deletes the endpoint and all associated delivery history.

Test Endpoint

Sends a webhook.test event to the endpoint. Use this to verify your endpoint is reachable and correctly verifying signatures.

Rotate Signing Secret

Generates a new signing secret for the endpoint. The previous secret is immediately invalidated.
After rotating a secret, update your server’s verification code with the new secret before the next delivery attempt.

Disable Endpoint

Temporarily disables the endpoint. No deliveries are attempted while disabled. Events that occur during this period are not queued.

Enable Endpoint

Re-enables a previously disabled endpoint.

List Deliveries

Returns a paginated list of delivery attempts for the endpoint, including status, response code, and timestamps.

Delivery Detail

Returns full details for a specific delivery attempt, including request/response headers and body. In the recorded request headers the signature and every custom header value are shown as ••••••; AppDNA’s own headers (Content-Type, X-AppDNA-Event, X-AppDNA-Delivery-Id, X-AppDNA-Timestamp, User-Agent) are shown as sent.

Retry Failed Delivery

Manually retries a failed delivery. The retry is attempted immediately, independent of the automatic retry schedule.

Event Catalog

Returns the full list of supported webhook event types with descriptions.
Your webhook integration is working correctly when you receive a webhook.test event at your endpoint and can successfully verify the signature.