Integration

Failures with no error

Every trap on this page shares one property: it produces no error. No exception, no 4xx, nothing in your logs. Several of them look actively healthy — a delivery log full of 200s while nothing at all is happening.

Test that state changed, not that nothing threw
This is the single most useful habit for a billing integration. After every step, assert against your own database — not the API response, and not the absence of an exception. Most of what follows was found in production by someone who had green tests.

At a glance

MistakeWhat you seeWhat catches it
Tenant id read from the wrong fieldEvery webhook 200s and is droppedAssert a row changed after a real delivery
Endpoint not subscribed to an eventA correct handler never runsRun the coverage endpoint
Entitlement gate never executesBlocked tenants keep full accessPut a tenant in a blocked state, expect 403
A creation path with no subscription rowPermanent free accessEnumerate every create path
Plan change made via checkoutTwo live subscriptions, double billingCount subscriptions for the customer
Card attached over the APISaves and lists, then fails at chargeAttempt a real charge in sandbox

Your tenant id is not where you expect

Subscription and payment payloads carry Throttle's internal customerId — a UUID that means nothing in your system. Your identifier is on the sibling customer object, as externalId.

externalId and externalCustomerId are different fields
Both appear on the same customer object. externalId is the id you set when you created the customer. externalCustomerId is a per-connection mapping and is null for direct API integrations — so reading it returns nothing, forever, without erroring.
Resolving the tenant
// Subscription payloads carry Throttle's internal customerId AND your own id.
// Yours is on the sibling customer object.
function resolveTenant(event) {
  //  Right: your identifier
  const external = event.data.customer?.externalId;
  if (external) return external;

  //  Wrong: a per-connection mapping, null for direct API integrations
  //  event.data.customer?.externalCustomerId

  // Fall back, then give up LOUDLY rather than returning 200 quietly.
  const local = lookupBySubscriptionId(event.data.subscriptionId);
  if (local) return local.tenantId;

  throw new Error(`Unresolvable tenant for ${event.type} ${event.id}`);
}

The failure mode is total and invisible: every subscription webhook is received, resolves to nothing, and returns 200. Throttle's delivery log shows delivered / 200 for all of them. If your checkout return path also syncs state, the user-present flow keeps working and hides it — until the first renewal, a month later, with no user present.

Cancellation fires two different events

Called withEventPayload
atPeriodEnd: falsesubscription.cancelledstatus: cancelled, cancelledAt set
atPeriodEnd: truesubscription.updatedstatus: active, cancelledAt: null, cancelAtPeriodEnd: true

Handle only subscription.cancelled and a scheduled cancellation is invisible to you: no confirmation email, and your Cancel button stays live because the subscription is still active. The buyer cancels, sees nothing change, and cancels again.

A handler for an event you never subscribed to

You can write a flawless handler for an event your endpoint does not receive, and nothing will ever tell you. There is an endpoint for exactly this: it reports event types your application emitted that no active endpoint subscribes to.

GET /api/v1/webhook-endpoints/coverage
curl -s https://api.usethrottle.dev/api/v1/webhook-endpoints/coverage \
  -H "X-API-Key: $THROTTLE_SECRET_KEY"

{
  "data": {
    "windowDays": 30,
    "unsubscribed": [
      { "type": "subscription.updated", "lastEmittedAt": "2026-08-08T02:11:04.000Z" },
      { "type": "subscription.paused",  "lastEmittedAt": "2026-08-06T17:45:22.000Z" }
    ]
  }
}

Run it after every integration change — it is cheap enough to put in CI and fail the build on a non-empty unsubscribed array. An endpoint subscribed to * always reports clean.

The gate that never runs

An entitlement check registered in the wrong place — before tenant context exists, on a router that does not cover your API, behind a condition that is never true — returns early on every request. Both the expiry lockout and all plan gating become dead code, and nothing distinguishes that from "no one is over their limit".

Prove it with a blocked tenant
Reading the code is not evidence. Put a tenant into a blocked state and confirm you get a 403. This is a two-minute test that no amount of code review substitutes for.

Creation paths without a subscription

Count the ways a tenant can come into existence: signup, invite, admin creation, seed script, import, test fixture. Each one that does not create a subscription row produces an account with permanent free access that never expires and is never asked to pay.

Nothing surfaces this. The accounts work perfectly. Enumerate the paths by grepping for your create call and check each one individually.

Changing a plan through checkout

Checkout always creates a new recurring subscription. Run an existing subscriber through it and they now have two, and are billed for both. Use POST /subscriptions/{id}/change-plan instead, and fall back to checkout only when there is no live subscription to change.

Cards that save but cannot be charged

Card entry must go through the hosted embed. A card attached by any other route saves successfully, lists successfully, and then fails at charge time with payment_method_not_chargeable — which you discover at the first renewal, not at integration time.

Use the embed via @usethrottle/payment-methods or hosted checkout. It is also what keeps card data out of your DOM and your PCI scope.

Test doubles kinder than production

A mock that returns a friendlier shape than the real API produces passing tests and a broken integration. This is the failure behind more than one bug on this page, on both sides of the boundary.

For anything crossing into Throttle, assert against a real captured payload. The delivery log gives you exactly what was sent; replay one as a fixture rather than writing what you think the shape is.

Start with the delivery log

GET /api/v1/webhook-deliveries?endpointId= shows the exact payload sent and the status your endpoint returned. When something is not happening and nothing is erroring, this is the first place to look — it distinguishes "never sent" from "sent and your handler swallowed it", which are completely different bugs.

Next: the build and test checklist turns this page into something you can tick through.