Best Practices & Troubleshooting

How to build a webhook handler that stays reliable, and how to diagnose deliveries that do not arrive.

Best practices

Respond quickly, process asynchronously

Your endpoint should acknowledge the delivery with a 2xx immediately and hand the actual work to a background job or queue. Slow handlers risk timeouts, which count as failed deliveries and trigger unnecessary retries.

app.post("/webhooks/factorial", (req, res) => {
  // 1. Verify
  if (req.get("x-factorial-wh-challenge") !== process.env.FACTORIAL_WH_CHALLENGE) {
    return res.sendStatus(401);
  }

  // 2. Persist the raw payload
  await rawEvents.insert({ body: req.body, receivedAt: new Date() });

  // 3. Acknowledge before doing any real work
  res.sendStatus(200);

  // 4. Process out of band
  await queue.enqueue("factorial-webhook", req.body);
});

Make your handler idempotent

Because of retries, your endpoint may receive the same event more than once. Payloads do not carry a unique per-delivery identifier — id is the identifier of the resource — so you cannot deduplicate on it alone.

The reliable approach is to make the operation itself idempotent: converge on the state in the payload rather than applying a delta. An upsert keyed on the resource id produces the same result whether it runs once or five times.

async function handle(resource) {
  // Idempotent by construction: same input, same end state
  await employees.upsert(
    { factorialId: resource.id },
    {
      fullName: resource.full_name,
      email: resource.email,
      active: resource.active,
      factorialUpdatedAt: resource.updated_at
    }
  );
}

If you must trigger a side effect that is not safe to repeat — sending an email, creating a payment, kicking off onboarding — guard it with your own deduplication key built from the payload, for example subscription_type plus the resource id plus updated_at. Store that key when the side effect succeeds and skip anything you have already seen.

const dedupeKey = `employees/employee/update:${resource.id}:${resource.updated_at}`;

if (await processedEvents.exists(dedupeKey)) return;

await startOnboardingWorkflow(resource);
await processedEvents.insert(dedupeKey);
📘

Why updated_at is in the key

Retries of one delivery carry an identical body, so they produce an identical key and are skipped. A genuine second change to the same resource carries a newer updated_at, so it produces a new key and is processed.

Log raw payloads

Store the raw incoming payload together with the received timestamp before processing it. This gives you an audit trail and lets you replay events while debugging, without waiting for the event to happen again in Factorial.

Monitor for failures

Set up alerting if your endpoint starts returning errors consistently. Silent failures cause your integration to drift out of sync with Factorial. Combine this with a technical support contact so Factorial's failure emails reach the right team.

Validate before trusting

Verify the x-factorial-wh-challenge header on every request, and treat the payload as untrusted input — validate the fields you rely on rather than assuming they are present.

Keep subscriptions tidy

Periodically list your subscriptions to check what is active. Delete subscriptions you no longer consume, and disable rather than delete when pausing temporarily.

Troubleshooting

SymptomLikely causeWhat to check
No events arriving at allEndpoint unreachable, wrong event type, or the tested action does not trigger the webhookConfirm the endpoint is publicly reachable over HTTPS; confirm the subscription_type matches the action you are performing; confirm the subscription is enabled
Events arriving for some employees onlyPermission-scoped tokenThe subscription inherits the permissions of the token that created it — see Webhook permissions
Your endpoint returns 400Payload parsing or validation issue on your sideCheck your JSON parsing, and that you are reading fields from the top level of the body — payloads are not wrapped in an envelope
Your endpoint returns 401Challenge mismatch, or your own auth middleware rejecting the requestVerify the stored challenge matches the subscription; make sure the webhook route is exempt from user authentication
Deliveries stopped after a period of errorsRetries were exhausted and the subscription was disabledFix the endpoint, then re-enable the subscription; replay any missed events from your logs
The same event processed twiceRetries after a slow or failed acknowledgementMake the handler idempotent and acknowledge faster — see Make your handler idempotent
Timeouts under loadWork is being done inside the requestMove processing to a queue and acknowledge immediately

Testing your endpoint

  1. Create a subscription pointing at your staging endpoint with a distinctive name.
  2. Perform the triggering action in Factorial — for the events in this section, that means the actual user action, not an API write that bypasses it.
  3. Check your raw payload log for the delivery.
  4. When you are done, delete or disable the staging subscription so it stops accruing failures.
📘

Still stuck?

If deliveries are still not arriving after these checks, get in touch with your Factorial contact and include the subscription id, the subscription_type, and the approximate time of the event you expected.


Did this page help you?