S Subster

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

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:

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.

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.

EventWhen it arrivesWhat is in data
entitlement.grantedaccess granted for a paymentguid, level, price_id, expires_at, subscription_id
entitlement.renewedsubscription entitlement renewedguid, subscription_id, price_id, level, expires_at — the new period end
entitlement.revokedaccess revokedguid; 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.completedpayment went throughguid, 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.processedmoney refundedguid, amount_cents, currency, subscription_id
subscription.payment_faileda recurring charge failedguid, 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_recovereda charge went through after a failureguid, subscription_id, amount_paid — amount charged in the currency's minor units, currency
subscription.pausedpayment collection paused, access keptguid, subscription_id
subscription.resumedpayment collection resumedguid, subscription_id; from the payment system side also status
subscription.plan_changedthe customer changed planguid, 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.openedthe payment is disputed at the bank, access untouched for nowguid, subscription_id, dispute_id, amount_cents, currency, reason — the reason as the bank states it, can be null
chargeback.resolvedthe dispute is settled: lost — access revoked, won — keptguid, 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.completeda saved-card charge went throughguid, price_id, amount_cents, currency, payment_intent_id, subscription_id
charge.failedthe same charge declined, no moneyguid, price_id, subscription_id
charge.requires_actionthe bank requires the customer's confirmation, no charge madeguid, price_id, subscription_id
quiz.completeda visitor reached the end of the quizguid, quiz_id
lead.captureda visitor left an email; no need to turn on the funnel step stream for itguid, quiz_id, session_id
user.property_updatedone buyer property writtenguid, property, value, redacted, block_id — which quiz question gave the answer, see “Buyer properties”
user.properties_completedall buyer properties in one arrayguid, properties
funnel.analytics_eventa funnel step: screen view, answer, email submitguid, 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.teston the cabinet buttonone 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.

MacroWhat 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

Next