Quick check

Three things to confirm before going deeper. Most “not firing” issues are actually “firing but the endpoint said no”.

  • Settings → Webhooks lists your endpoint and its status is Active.
  • The event you expected is in the endpoint’s Events subscription list (e.g. order.created, order.paid).
  • The endpoint’s Deliveries tab shows recent attempts for the event in question. If you see them with non-2xx responses, the failure is on your end, not ours.
Fire the test event first

The fastest way to confirm your endpoint is reachable at all is to click Send test event on the endpoint row. That sends a synthetic webhook.test payload — see Using the test event.

Endpoint isn’t subscribed to the event

The symptom

The thing happened (you can see the order, the payment, the new client in their respective module), but the Deliveries tab for your endpoint has no row for it. Even a fresh delivery from the test button works fine.

Why it happens

Outbound webhooks fan out per-event-type. If your endpoint isn’t subscribed to the event type that fired, we don’t enqueue a delivery for it. The most common variants:

  • Endpoint was created subscribed only to order.created, but you’re expecting payment.created events too.
  • Endpoint exists but is marked Inactive (toggled off, not deleted).
  • The event in question is scoped to a different store, and the endpoint is pinned to a different store.

How to fix it

  1. Open Settings → Webhooks → your endpoint → Events. Tick every event type you want to receive. You can subscribe to all of them — the cost is bandwidth, not money.
  2. Confirm the endpoint is Active at the top of the detail panel.
  3. If you operate multiple stores, make sure the endpoint is on the store the event came from. Endpoints are store-scoped.
  4. Trigger the underlying event again (place a test order, record a test payment) and watch the Deliveries tab. The new row should appear within a few seconds.

Your endpoint rejected the signature

The symptom

The Deliveries tab shows attempts but they all return 401 or 403 from your endpoint. Your endpoint logs say “invalid signature”.

Why it happens

Your verification code is computing the HMAC differently than we are. Our outbound signature uses the same Stripe-style pattern as the inbound Integration API:

  • Header: X-PC-Signature
  • Format: t=<unix-seconds>,v1=<hex>
  • Signed string: <t> + "." + <raw-body>
  • Algorithm: HMAC-SHA256, using the endpoint’s signing secret as a UTF-8 string

If your code expects a different header name, a base64 body, or signs just the body without the timestamp prefix, you’ll get a mismatch.

How to fix it

Use these reference verifiers. They’re the exact format we send.

// Node.js
const crypto = require('crypto');

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(',').map(p => p.split('='))
  );
  const t  = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;
  // Reject anything older than 5 minutes
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`, 'utf8')
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(v1, 'hex')
  );
}
# Python
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split('=', 1) for p in header.split(','))
    t  = parts.get('t')
    v1 = parts.get('v1')
    if not (t and v1):
        return False
    if abs(time.time() - int(t)) > 300:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
  1. Make sure your handler reads the raw request body bytes, not a JSON-parsed-and-restringified version. Frameworks like Express need express.raw({type: 'application/json'}) for the webhook route specifically.
  2. Use a constant-time comparison (timingSafeEqual / compare_digest) — not ===.
  3. If you rotated the signing secret on our side, update your env var before re-enabling the endpoint. The old secret is invalidated immediately.

Endpoint is timing out

The symptom

The Deliveries tab shows the response as timeout with latency around 10,000 ms. Your endpoint logs may show the request landing and a slow handler, possibly succeeding eventually — but we already gave up.

Why it happens

We give your endpoint 10 seconds to return any response. After that, we drop the connection and mark the attempt failed. Subsequent retries follow the same 10-second budget.

10 seconds sounds generous, but if your handler synchronously calls a slow downstream API, a third-party service of your own, or does heavy database work, it can blow through quickly under load.

How to fix it

Move to 202-accept-then-process-async. Your handler should:

  1. Verify the signature.
  2. Persist the event to your own queue (database row, SQS, Cloud Tasks, Redis).
  3. Return a 200 (or 202) within ~1 s.
  4. Process the event off a worker.

This is the standard pattern for any webhook receiver and matches the guidance Stripe / GitHub / etc. give — the receiver’s job is to acknowledge, not to do the work.

Delivery is dropped after 5 attempts

The symptom

The delivery row in Settings → Webhooks → Deliveries shows status=dropped. You can see all 5 attempts in the history, each with a non-2xx or timeout.

Why it happens

We retry every failed delivery on this backoff schedule:

AttemptDelay from previous
1Immediate
2~1 minute
3~5 minutes
4~30 minutes
5~2 hours
(final retry)~8 hours

If all attempts fail, the delivery is marked dropped and we don’t auto-retry further — that would just spam an endpoint that’s clearly broken.

How to fix it

  1. Fix the underlying problem on your endpoint (verify the signature, return 200, whatever the original failure was).
  2. Run a manual retry: open the dropped delivery row and click Retry. That re-enqueues with status=pending; the next dispatcher run picks it up.
  3. If you have many dropped deliveries, fix the endpoint first, then bulk-retry from the Deliveries tab (multi-select + Retry selected).
Endpoint auto-suspension

If an endpoint accumulates >50 consecutive dropped deliveries we auto-suspend it (toggle Active to off). That stops us from queueing more deliveries to a clearly-broken endpoint. Fix the issue, click Activate, then bulk-retry the dropped backlog.

Payload too large

The symptom

The delivery shows status=skipped with the response body containing payload_too_large. Other deliveries to the same endpoint work fine.

Why it happens

Outbound webhook payloads have a 6 MB cap. An event that includes a very large embedded resource (an order with hundreds of line items + per-line metadata, a contract with attachments embedded inline) can exceed this.

How to fix it

  1. If the event type lets you opt into a leaner payload, do that — some events have a “send id only, fetch detail via API” mode in Settings → Webhooks → endpoint → Advanced.
  2. Trim the upstream record. For example, if you’re embedding large base64 attachments in a record we then emit, push the attachment to your own storage and store a URL instead.
  3. If neither helps, drop us a note — we may be able to chunk a particular event type into pages.

Using the test event

Every endpoint has a Send test event button. Clicking it enqueues a synthetic webhook.test delivery with this body:

{
  "event": "webhook.test",
  "id":    "evt_test_xxxxxxxxxxxxxxxx",
  "ts":    1716309600,
  "data":  { "hello": "world" }
}

It’s signed the same way as any real event. Use it for:

  • Confirming your endpoint is reachable at all (DNS resolves, TLS is valid, our IP isn’t firewalled).
  • Confirming your signature verifier works.
  • Smoke-testing after rotating the signing secret.

Test events do not affect any other system — they don’t create rows, they don’t trip rate limits, they only land in the Deliveries tab so you can inspect.

Reading the diagnostics

Settings → Webhooks → endpoint → Deliveries shows one row per attempt. Columns:

ColumnWhat to look at
attempt1 through 5. After 5 with no 2xx, the delivery is marked dropped.
event_typee.g. order.created, payment.created. Confirms what we tried to deliver.
response_statusWhat your endpoint returned. 2xx = good. 0 or empty + latency ≈ 10000 = timeout.
response_headersCaptured for debugging. Look for www-authenticate or content-type hints.
response_bodyFirst 500 characters of your response body. Use this to read your endpoint’s own error message.
latencyMilliseconds from our POST to your final byte. Sustained >3,000 ms is a flag.
scheduled_for_retryWhen (if ever) the next attempt is queued. Empty = no further retry.

When to write to support

Email {{CONTACT_EMAIL}} with:

  1. The endpoint id (the public part — visible at the top of the endpoint detail panel).
  2. The delivery id of the specific delivery that’s wrong, copied from the Deliveries tab.
  3. The full delivery row text — Copy as text on the row gives us attempts, response codes, and latency in one paste.
  4. The first 500 chars of response_body if you suspect a verifier mismatch.
  5. Your endpoint’s recent log line for the same request (use the timestamp in our row to find it).

Related: Outbound webhooks feature guide · API reference · Troubleshooting hub.