TidePay

Webhooks

Receiving and verifying signed event notifications.

Tidepay sends signed HTTP POST requests to your configured webhook URL whenever a subscription or payment event happens, so you don't have to poll the API for state changes.

Configuring your endpoint

Set your webhook URL from the dashboard: Settings → Business, under "Webhook URL". Your Signing Secret (Settings → API keys) is generated automatically the first time you save a webhook URL.

Two URLs can be configured there:

FieldReceivesRequired
Webhook URLLive eventsYes
Sandbox webhook URLSandbox (test key) eventsNo

Leaving the sandbox field empty sends sandbox events to your live URL — tell them apart with the payload's isSandbox field. Setting it lets you point test traffic at a tunnel or a collector like webhook.site while production keeps flowing to your real endpoint.

Live events are never delivered to the sandbox URL, so a stale test endpoint can't swallow real payment events.

From the API

Both endpoints can also be set programmatically, which is the practical way to point a sandbox integration at an ephemeral tunnel from a script or CI job:

curl -X PATCH https://api.tidepay.cc/v1/webhooks/endpoint \
  -H "X-API-Key: tp_test_..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://abc123.ngrok.app/webhooks/tidepay"}'

The key decides which endpoint you are editing: a tp_test_... key writes the sandbox URL, a tp_live_... key writes the live one, and neither can touch the other. Send {"url": ""} to unset it. GET /v1/webhooks/endpoint reads back the endpoint for your key's environment.

One secret for both environments

Unlike API keys, there is a single Signing Secret shared by both sandbox and live events — it doesn't come in test/live pairs, and it stays the same whichever of the two URLs receives the event. Your verification code is therefore identical for both.

Localhost will not work

Every delivery is validated against private, loopback, and link-local addresses before the request is made — a webhook URL pointing at localhost or a private IP is rejected, in sandbox too. Use a public tunnel (ngrok, Cloudflare Tunnel) or a hosted collector when developing locally.

Events

EventFired when
subscription.createdA subscription is created, before the subscriber has authorized it on-chain
subscription.activeThe first charge on a subscription settles successfully
subscription.canceledA subscription is canceled
payment.succeededAny charge (recurring or one-off) settles successfully
payment.failedA charge fails (missing or insufficient on-chain authorization, insufficient balance, a reverted transaction, etc.)
payment.canceledA pending one-off payment is canceled via POST /payments/{paymentId}/cancel
customer.createdA customer record is created
customer.updatedA customer record is updated
customer.deletedA customer record is deleted
wallet.updatedA merchant's settlement wallet for a token is registered or changed — see below, this is a security-relevant event

Payload shape

Every webhook body has the same envelope — only data varies by event:

{
  "id": "evt_...",
  "event": "payment.succeeded",
  "createdAt": "2026-08-03T14:00:00.000Z",
  "isSandbox": false,
  "data": {}
}

data carries the relevant resource IDs (subscription, charge, customer, etc.) needed to look up the full object via the API, rather than duplicating the entire resource inline.

customer.created

{
  "customerId": "cust_...",
  "wallets": [
    {
      "chainFamily": "evm",
      "address": "0x71c7656ec7ab88b098defb751b7401b5f6d8976"
    }
  ]
}

wallets is [] if the customer was created without one. A customer can have at most one entry per chainFamily (evm, solana) — see Customers.

customer.updated

{
  "customerId": "cust_...",
  "wallets": [
    {
      "chainFamily": "evm",
      "address": "0x71c7656ec7ab88b098defb751b7401b5f6d8976"
    },
    {
      "chainFamily": "solana",
      "address": "5R9YAjKykDWt2gWeaxqSSDtrxyppndm9446RbnwRS6C5"
    }
  ]
}

customer.deleted

{ "customerId": "cust_..." }

No wallets — the record is already gone by delivery time.

subscription.created

{
  "subscriptionId": "sub_...",
  "offerId": "offer_...",
  "priceId": "price_...",
  "status": "pending"
}

Fired immediately on POST /subscriptions, before the subscriber has approved an on-chain allowance — status is always "pending". Not fired on an idempotency replay of an existing subscription.

subscription.active

{ "subscriptionId": "sub_...", "offerId": "offer_...", "priceId": "price_..." }

Fired the first time a subscription's charge cycle settles successfully — the transition from pending to active.

subscription.canceled

{ "subscriptionId": "sub_...", "offerId": "offer_...", "priceId": "price_..." }

reason is included only when the billing cron auto-cancels the subscription, not when you cancel it yourself via POST /subscriptions/{subscriptionId}/cancel:

{
  "subscriptionId": "sub_...",
  "offerId": "offer_...",
  "priceId": "price_...",
  "reason": "max_cycles_reached"
}

reason is one of max_cycles_reached (the pricing option's maxCycles limit reached) or max_failed_cycles_reached (its maxFailedCycles limit reached).

payment.succeeded

The data shape depends on whether this was a subscription's billing-cycle invoice or a one-off payment:

{ "subscriptionId": "sub_...", "invoiceId": "sub-pay-..." }
{ "paymentId": "pay_..." }

payment.failed

Same split as payment.succeeded — a subscription's billing-cycle invoice includes subscriptionId/invoiceId, a one-off payment (POST /payments) includes paymentId instead. Both variants add a reason:

{
  "subscriptionId": "sub_...",
  "invoiceId": "sub-pay-...",
  "reason": "insufficient_allowance"
}
{ "paymentId": "pay_...", "reason": "insufficient_allowance" }

reason observed values: insufficient_allowance, insufficient_balance, pull_reverted (all charge types), plus forward_reverted, platform_transfer_reverted, distribute_reverted (one-off payments only).

payment.canceled

{ "paymentId": "pay_..." }

Only reachable while the payment is still pending — once a pull is broadcast on-chain it can no longer be canceled.

wallet.updated

{
  "tokenKey": "usdc-base",
  "previousWallet": "0x71c7656ec7ab88b098defb751b7401b5f6d8976",
  "newWallet": "0x8f3cf7ad23cd3cadbd9735aff958023239c6a063",
  "source": "api"
}

Fired whenever a merchant's settlement wallet for a token is registered, changed, or removed — via POST/DELETE /wallets or the dashboard's Settings → Payments. source is "api" or "dashboard", depending on which surface made the change. newWallet is null when the wallet was removed rather than changed to another address — payouts still don't stop, they fall back to any other wallet you have registered, or to the destination named on the offending offer's own pricing option.

This is a security signal, not a routine update

This event redirects where your future payouts settle. Tidepay also sends a security-alert e-mail to the account owner whenever it fires. If you receive this event and did not make the change yourself, treat your API key and dashboard session as compromised — rotate your API key and change your password immediately.

Full JSON Schema

Each event's exact data schema (including which fields are required vs. optional) is also published in the OpenAPI spec under x-webhooks — see /api/openapi.json.

Verifying the signature

Each request includes two headers:

X-Tidepay-Timestamp: 1735689600
X-Tidepay-Signature: 5f4dcc3b5aa765d61d8327deb882cf99...

The signature is an HMAC-SHA256 of the timestamp and the raw request body (before any JSON parsing), signed with your Signing Secret:

signature = HMAC-SHA256(webhookSecret, `${timestamp}.${rawBody}`)

Recompute it on your end and compare using a constant-time comparison — never === on the raw strings, which leaks timing information an attacker can use to guess the signature byte by byte.

import { createHmac, timingSafeEqual } from "node:crypto";

function isValidSignature(
  secret: string,
  timestamp: string,
  rawBody: string,
  signature: string,
): boolean {
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

Read the raw body

Your HTTP framework's JSON body parser typically re-serializes the parsed object, which can silently change field order or spacing and break the signature check. Verify against the exact bytes Tidepay sent — read the raw body before parsing it as JSON, or configure your framework to expose it separately (e.g. Express's express.raw(), or reading the Request stream directly in a Next.js route handler).

Retries

If your endpoint doesn't respond with a 2xx status (or the request fails outright — timeout, connection refused, etc.), Tidepay retries with exponential backoff: 1, 5, 30, 120, and 360 minutes after the previous attempt, for up to 6 total attempts. After the last attempt fails, the delivery is marked failed and not retried again.

Handle deliveries idempotently — use the payload's id field to detect and ignore duplicates, since a retry can occur even after your endpoint successfully processed the event but the response was lost in transit.

Rotating your Signing Secret

If your secret is ever exposed (e.g. committed to a public repo, logged somewhere insecure), rotate it from Settings → API keys → "Rotate signing secret". This immediately invalidates the old secret — every subsequent webhook is signed with the new one, so update your endpoint's verification code first if you want zero missed/rejected deliveries during the switch.

Sandbox webhooks

Sandbox events use the exact same signature scheme as live events — no special verification logic needed. See Test mode for how sandbox and live events are distinguished.

On this page