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.
At a glance
| Mistake | What you see | What catches it |
|---|---|---|
| Tenant id read from the wrong field | Every webhook 200s and is dropped | Assert a row changed after a real delivery |
| Endpoint not subscribed to an event | A correct handler never runs | Run the coverage endpoint |
| Entitlement gate never executes | Blocked tenants keep full access | Put a tenant in a blocked state, expect 403 |
| A creation path with no subscription row | Permanent free access | Enumerate every create path |
| Plan change made via checkout | Two live subscriptions, double billing | Count subscriptions for the customer |
| Card attached over the API | Saves and lists, then fails at charge | Attempt 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.
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.
// 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 with | Event | Payload |
|---|---|---|
atPeriodEnd: false | subscription.cancelled | status: cancelled, cancelledAt set |
atPeriodEnd: true | subscription.updated | status: 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.
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".
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.