Webhooks
Webhooks push feed events to your backend as they happen: register an HTTPS endpoint once and every activity, reaction, and follow change on your tenant is delivered to it. Delivery runs on Hookdeck Outpost as isolated infrastructure — it owns destination storage, HMAC-SHA256 signing, retries with backoff, and the delivery dashboard, so a slow or failing consumer never touches the feed API’s write path.
One sentence of positioning: webhooks are the server-to-server sync story
(mirror feed events into your own database, trigger downstream jobs), while
live-mode head polling is the client liveness story (live: true in the React
hooks polls a cheap change signal to refresh a rendered feed) — use both, for
different jobs.
Registering an endpoint
Section titled “Registering an endpoint”Webhook management is server-token only — it lives in @dropinnodex/server,
never in the browser SDKs. A user-token call to any webhook route returns
FORBIDDEN.
import { DropInServer } from '@dropinnodex/server'
const server = new DropInServer({ tenantId: 'acme', apiKey: process.env.DROPIN_API_KEY!, apiSecret: process.env.DROPIN_API_SECRET!,})
// Register — the response includes the destination id and the signing secret.const dest = await server.webhooks.create({ url: 'https://acme.com/dropin-hooks' })console.log(dest.id) // pass this to webhooks.remove() laterconsole.log(dest.credentials) // the HMAC signing secret — store it NOW, in your secret store
// Round out the surface:const all = await server.webhooks.list()await server.webhooks.remove(dest.id)The create response is the moment to capture the signing secret — persist it in
your secret store right away and verify every delivery with it. A destination
subscribes to all event topics (topics: ["*"]); filter by type in your
handler if you only care about some events.
Events
Section titled “Events”Six event types fire, each only when a write actually changed a row — a
no-op (double-react, duplicate follow, re-POST of the same
(foreign_id, time) activity) emits nothing:
| Event | Payload (data) |
|---|---|
activity.added |
the full activity (same shape the API returns) |
activity.removed |
{ id } |
reaction.added |
{ id, activity_id, user_id, kind, custom, created_at } |
reaction.removed |
{ id, activity_id, kind, user_id } |
follow.added |
{ source, target } |
follow.removed |
{ source, target } |
An example activity.added payload:
{ "id": "b3f0c1d2-…", "actor": "user:maya", "verb": "workout", "object": "workout:1234", "target": null, "foreign_id": "maya-w0", "time": "2026-08-01T09:30:00.000Z", "custom": { "sport": "run", "durationMin": 45 }, "origin_feed": "user:maya", "reaction_counts": {}}Every event carries a deterministic idempotency id of the form
<type>:<entity id> — e.g. reaction.added:c81a… — so a retried delivery of
the same event always carries the same id.
Delivery semantics — what your consumer must do
Section titled “Delivery semantics — what your consumer must do”Delivery is at-least-once: internal retries and Outpost’s own retry-with-backoff mean the same event can arrive more than once. Write your handler accordingly:
- Verify the signature on every request (below). Reject anything that fails.
- Dedupe on the event id. Same id = same event; process it once and treat repeats as already-handled.
- Return 2xx fast. Acknowledge immediately and do real work async — a slow response is what triggers redelivery in the first place. A non-2xx (or a timeout) is retried with backoff.
Ordering is not guaranteed across events — use the payload’s timestamps, not arrival order. And delivery is deliberately best-effort from the feed’s side: a webhook can never block, slow, or fail the domain write that caused it, so treat webhooks as a near-real-time signal, not a transactional log. If you need a guaranteed full mirror, periodically reconcile by reading feeds.
The other direction — your triggers writing into the feed
Section titled “The other direction — your triggers writing into the feed”If you mirror your own events into the feed (a database trigger, a queue consumer, a Cloud Function), that producer is almost certainly at-least-once too. You do not need to build a dedupe layer for it. Every write is idempotent server-side, and the notification and webhook are gated on a row actually landing — not on the request arriving:
| Redelivered write | What happens the second time |
|---|---|
Follow (userFollow) |
ON CONFLICT DO NOTHING — no duplicate edge, no second notification, no second follow.added |
Activity with foreign_id + time |
the original activity is returned unchanged, no second fan-out |
Activity without foreign_id |
a genuine duplicate — dedupe is opt-in, so pin a foreign_id on anything retryable |
| User upsert | an upsert; running it twice is a no-op |
So a trigger that fires twice produces one edge and one notification. The one
case that does duplicate is an activity posted without foreign_id — see
Idempotency & foreign_id, which
also covers why foreign_id requires time and why re-posting a deleted
identity returns 409.
Verifying signatures
Section titled “Verifying signatures”Deliveries are HMAC-SHA256 signed with the secret returned by
webhooks.create. Outpost documents the
exact header names and signed-payload format for webhook destinations — the
shape is the standard scheme: compute the HMAC of the raw request body (plus the timestamp
header, if present) with your stored secret, and compare it to the signature
header in constant time.
// Express-style sketch — check Outpost's docs for the exact header names.import { createHmac, timingSafeEqual } from 'node:crypto'
app.post('/dropin-hooks', express.raw({ type: 'application/json' }), (req, res) => { const expected = createHmac('sha256', process.env.DROPIN_WEBHOOK_SECRET!) .update(req.body) // the RAW body — never a re-serialized parse .digest() const given = Buffer.from(String(req.headers['<signature header>']), 'base64')
if (given.length !== expected.length || !timingSafeEqual(given, expected)) { return res.status(401).end() }
const event = JSON.parse(req.body.toString('utf8')) // 1. dedupe on the event id 2. ack fast 3. process async res.status(200).end()})Two rules that hold regardless of header names: sign-check the raw bytes
(any JSON re-serialization breaks the HMAC), and compare with
timingSafeEqual, never ===.
What does not fire webhooks
Section titled “What does not fire webhooks”Batch imports are quiet by design. None of the /v1/batch/* routes emit
webhooks (or notifications, or live-mode pings) — a cold-start import must not
fire thousands of deliveries at your own backend for data it already has. See
Migrating existing data.
Related
Section titled “Related”- Notifications — the in-app equivalent of these events, read through the SDKs instead of delivered to your backend.
- Activities — the idempotency rules your producers rely on.
- Migrating existing data — the quiet import path.
- Limits — rate limits your webhook-triggered writes charge.