Webhooks
You will set up a receiver for our events, verify the signature and learn what always arrives and what may not.
Payment happens in the browser, but your server grants access in the app. So it need not poll us, we call your address ourselves: access granted, subscription renewed, money refunded. On your side one handler is needed: accept the request, verify the signature, respond.
Before you start
- Pro or Business plan. The app connection is paid as a whole: on the free plan the receivers section is closed and events go nowhere.
- An
httpsaddress reachable from the internet. We do not call local or internal network addresses and do not follow redirects. - Project owner or admin. They set up receivers. No cabinet access? Ask them to do the steps below and pass you the secret. Who invites whom and which roles exist — “Project and team” and “Hand off to a developer” on the owner's track.
How to set up a receiver
Cabinet: Project settings → App connection → For developers → “Events to your server” → “Add an event receiver”.
The window has two choices: your handler's address and the set of event types. Right after creation the cabinet shows the endpoint signing secret whsec_… once — save it then; we never show it again. Address and types change at any time; the secret is replaced by a separate “Rotate the secret” command, and the new one is also shown once.
The receiver row has two more commands:
- “Send a test event” sends
webhook.testto your address. It changes no data and checks intake and signature before the first sale. Other types come only from real events — to see them in advance, make a purchase in the test payment environment: live and test events go out the same way. - “Log” shows deliveries for the last 30 days: your server's response code, the attempt number and the reason if we decided not to send.
What arrives
Each event is one POST with a body like this:
{
"id": "cs_123",
"type": "entitlement.granted",
"created": 1789992000,
"data": {
"guid": "abc123def456",
"level": "premium",
"price_id": "price_…",
"expires_at": "2026-10-21T00:00:00.000Z",
"subscription_id": "sub_…",
"occurredAt": "2026-09-21T12:00:00.000Z"
}
}
The body has two times. created is when we queued the delivery, occurredAt when the fact happened on our side or the payment system's. After retries and catch-up trust the second: by the first, the event looks as if it happened now. The table below does not repeat occurredAt; webhook.test lacks it, and if another event lacks the field, use created.
occurredAt is the only camelCase name in the body; the other fields use underscores.
The body has no personal data: the event names the customer by identifier (guid), and you fetch the email with your key — “Buyer properties”; access details — “Check the entitlement”.
Subscription and dispute events — subscription.* and chargeback.* — can have a null buyer identifier: we did not find the customer for the subscription. The field still always arrives. Match such an event to your records by subscription_id — it also comes in entitlement.granted.
The event ID comes both in the body and in the X-Web2App-Event-Id header. It is opaque: do not parse or decode it, compare whole strings.
Signature verification
The X-Web2App-Signature header carries a timestamp and signature: t=<unix-seconds>,v1=<hex>. Recompute HMAC-SHA256 with the endpoint signing secret over the string “timestamp, dot, request body” and compare with the signature in constant time.
Take the whole secret, whsec_ included. The signing secret from “Authentication” starts the same, but with it the signature will not match.
The timestamp here is in seconds. In the other direction, when your server signs requests to us, X-Signature-Timestamp is in milliseconds — verification code does not carry over (Authentication).
Compute over the raw body as received. A body re-serialised from a parsed object gives a different signature, even with identical values.
Separately check the timestamp's freshness: then an intercepted and resent request fails. How much skew to allow between the timestamp and your clock is up to you — five minutes is plenty.
The header can carry two signatures. That is how two days after a secret change look: we sign with both old and new, so you can swap the value without downtime. If either matches, the signature is valid.
const crypto = require("crypto");
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return header
.split(",")
.filter((p) => p.trim().startsWith("v1="))
.some((p) => {
const got = Buffer.from(p.trim().slice(3), "hex");
const exp = Buffer.from(expected, "hex");
return got.length === exp.length && crypto.timingSafeEqual(got, exp);
});
}
The same in Python:
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.strip().split("=", 1) for p in header.split(",")]
t = next((v for k, v in parts if k == "t"), None)
if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(k == "v1" and hmac.compare_digest(v, expected) for k, v in parts)
Delivery and retries
Respond with 2xx within ten seconds — after that we drop the connection and count the attempt as failed. Verify the signature before responding, move heavy processing to the background.
- We retry on 5xx, 429 and timeout: up to eight attempts, the pause doubles from thirty seconds, the whole window about an hour.
- We do not retry on other 4xx: to us it means “the address understood and refused”.
- One event can arrive twice. Store the IDs of processed events and drop repeats on your side.
- A test event has its own rules: five attempts with pauses from five seconds, so the cabinet answers quickly.
Why the access grant event arrives twice
The customer reaches the “payment succeeded” page faster than the payment system's notification reaches us. We do not want to wait: access is granted at once, and the grant event goes out with an empty term and an ID ending in -early.
When the notification with the real term comes, a second identical event goes out — same customer, price and level, but with a date and a clean ID. The IDs differ on purpose: your repeat filter must not swallow the update. Handle the second as an update to already granted access, not as a new purchase. One-time purchases do not split like this.
When we turn off your address
Once a day we look at the last three days: failed deliveries and not one successful — the address is turned off and the project owner gets an email. One successful delivery of a real event within those three days keeps the address; a test event does not count. While the address is off, events are not sent and not resent later: what fell on those days you will not get.
The state is shown in the receiver row — the label “on” or “off”; the “Turn on” button is there too.
What events exist
Subscribe to what you handle: an extra type means extra requests to you and extra log rows.
| Event | When it arrives | What is in data |
|---|---|---|
entitlement.granted | access granted for a payment | guid, level, price_id, expires_at, subscription_id |
entitlement.renewed | subscription entitlement renewed | guid, subscription_id, price_id, level, expires_at — the new period end |
entitlement.revoked | access revoked | guid; a subscription revocation also carries subscription_id, price_id, level and, if the reason is known, reason. A revocation after a refund, a lost dispute, a failed async payment or a refund of a one-time add-on comes with guid only: there is no reason, and which entitlement was revoked cannot be told from the event — request the entitlement by guid and remove on your side what the answer no longer has. For a refund or dispute, refund.processed and chargeback.resolved arrive alongside |
purchase.completed | payment went through | guid, price_id, amount_cents, currency, subscription_id. subscription_id: null — a one-time purchase; price_id, amount_cents and currency can be null when we could not determine them |
refund.processed | money refunded | guid, amount_cents, currency, subscription_id |
subscription.payment_failed | a recurring charge failed | guid, subscription_id, amount_due — amount due in the currency's minor units, as the payment system gives it, currency, will_retry — whether there will be another attempt, grace_period_ends_at — ISO date until which access lives, next_payment_attempt — next attempt time in unix seconds, null — no more attempts |
subscription.payment_recovered | a charge went through after a failure | guid, subscription_id, amount_paid — amount charged in the currency's minor units, currency |
subscription.paused | payment collection paused, access kept | guid, subscription_id |
subscription.resumed | payment collection resumed | guid, subscription_id; from the payment system side also status |
subscription.plan_changed | the customer changed plan | guid, subscription_id, previous_price_id, new_price_id, current_period_end, access_level_change_deferred, access_level_applies_at. With access_level_change_deferred: true change the access level not at once but at access_level_applies_at; null there — at the next renewal |
chargeback.opened | the payment is disputed at the bank, access untouched for now | guid, subscription_id, dispute_id, amount_cents, currency, reason — the reason as the bank states it, can be null |
chargeback.resolved | the dispute is settled: lost — access revoked, won — kept | guid, subscription_id, dispute_id, outcome — the dispute status from Stripe as is: won, lost, warning_closed and others; unknown — no status came. Access is revoked only on lost; amount_cents, currency |
charge.completed | a saved-card charge went through | guid, price_id, amount_cents, currency, payment_intent_id, subscription_id |
charge.failed | the same charge declined, no money | guid, price_id, subscription_id |
charge.requires_action | the bank requires the customer's confirmation, no charge made | guid, price_id, subscription_id |
quiz.completed | a visitor reached the end of the quiz | guid, quiz_id |
lead.captured | a visitor left an email; no need to turn on the funnel step stream for it | guid, quiz_id, session_id |
user.property_updated | one buyer property written | guid, property, value, redacted, block_id — which quiz question gave the answer, see “Buyer properties” |
user.properties_completed | all buyer properties in one array | guid, properties |
funnel.analytics_event | a funnel step: screen view, answer, email submit | guid, event_type — one of QUIZ_VIEW, QUIZ_START, SCREEN_VIEW, SCREEN_ANSWER, QUIZ_COMPLETE, EMAIL_SUBMIT, PAYWALL_VIEW, PRICE_CLICK, CHECKOUT_START, TRAFFBACK, PAYMENT_UNAVAILABLE, CYCLE_GUARD_TRIPPED; session_id, quiz_id, paywall_id, screen_id |
webhook.test | on the cabinet button | one text field |
The funnel step stream is off by default: subscribing to the type is not enough; in the receiver window, tick which steps to send. Beyond three hundred such events a minute per address, the rest are skipped without retries. For buyer properties the list is optional: empty means “send all”.
For a question with preset options, value carries the chosen option's ID, not its text. Values marked personal — email and anything typed by hand — come empty next to the redacted flag. How to map options and fetch personal values with a request — “Buyer properties”.
Why some events may not arrive
On the public funnel page a visitor answers the tracking consent banner, and their choice gates observational events. A refusal is respected in any country; for visitors from Europe — EU, UK, Switzerland, Norway, Iceland, Liechtenstein — silence is closed too, when the person leaves the banner without choosing.
May not arrive: quiz completion, email capture, the funnel step stream, both buyer property events.
Always arrive: everything about access and money — granting, renewing and revoking an entitlement, payment, refund, charge failure and recovery, pause, plan change, disputes, saved-card charges. Their basis is performing the contract with the customer, not ad consent, so granting access after payment does not stop on a refusal.
The receiver log tells a skip from a delivery failure. An attempt row carries your server's response code — we sent, and the problem is on your side. A decision row carries no response code but names the reason and the number of repeats in an hour — we did not send, and why.
Sales reports to the tracker
The neighbouring cabinet block holds a second mechanism that is confused with the first. It is for when a partner tracker, not your server, waits for events: instead of a signed POST we call a GET by the address template you set, substituting values right into the address.
The template is set on the same “For developers” tab: the “Sales reports to the tracker” block, the “Postback address” field. A report goes out when a visitor leaves an email, a payment goes through or the app is installed, and when your server has told us about a sign-up, install or purchase.
| Macro | What we put in its place |
|---|---|
{click_id} | the partner click id: click_id, sub_id or clickid from the address the visitor came to the funnel by, or the clickId field of an event from your server. Google, Meta and TikTok click ids do not go here; no click id — empty value |
{status} | lead — email or a sign-up from your server, purchase — a purchase, app_installed — an install |
{payout} | the purchase amount in the main currency unit: 19.99, and a whole number for currencies without cents |
{txid} | the deal number: the visitor session for an email, the payment for a purchase; empty for an install or sign-up |
https://your-server.io/hook?event={status}&click_id={click_id}&txid={txid}
The cabinet will not save a template with an unknown macro and will name it, so use only these four. Report retries are the same as for webhooks.
Tracking consent applies here too: no report goes out about a recognised visitor without consent, and where the banner is shown we store the click id only after “Accept”.
In short
- The project owner sets up the receiver in the cabinet; the secret is shown once.
- The signature is computed over the raw body with the timestamp; during a secret change there are two signatures.
- 2xx within ten seconds, otherwise up to eight retries within an hour; three days of nothing but failures — we turn the address off.
- Deduplication on your side is mandatory: one event can arrive twice.
- Money and access events always arrive; observational ones only with the visitor's consent.
Next
- Troubleshooting — what to check when an event did not arrive, or arrived but access does not match.
- Check the entitlement — ask us directly when events are not enough or the handler fell behind.
- Authentication — the key your server uses to fetch the email and purchase details.
- How we recognise the customer — where the identifier the event names the person by comes from.
- Recipes — the full webhook handler as ready-made Node code.
- Send an event to us — the reverse direction: your server tells us about an event.