Your database says a position closed twice. Or it has a position.closed
row for a ticket that, according to the same database, never opened. Nothing
is wrong with the sender. Your consumer just assumed guarantees that no
webhook system gives you.
What a consumer must assume comes down to three things: at-least-once delivery, no ordering, and a sender that will eventually give up. We'll use MetaKit's own webhook retries and delivery behaviour as the worked example, because the numbers are public and the llms.txt spells them out. The rules apply to Stripe, GitHub, and anything else that POSTs JSON at you.
Webhook retries mean at-least-once, and nothing stronger
MetaKit delivers each event to each of your webhook endpoints with up to five attempts. The delays between attempts are 0.5s, 1s, 2s and 4s. Each attempt has a 5-second request timeout. A non-2xx response or a timeout counts as a failure and triggers the next attempt.
Do the arithmetic on the worst case: five attempts that each hang for the full five seconds, plus 7.5 seconds of backoff, is about 32 seconds from first attempt to final give-up. The best case is a single 50ms POST.
"At least once" hides in the word timeout. Your handler receives the request, writes to the database, and returns 200 at 5.2 seconds. The sender stopped listening at 5.0. It records a failure and sends the same event again half a second later. You now have the event twice, and from the sender's side, it did exactly what it promised.
There is no delivery mode that fixes this. Exactly-once across a network boundary isn't a thing a sender can provide; it's a property your consumer builds by being idempotent.
Retries reuse the same timestamp and signature
Here's the detail that matters for dedupe. MetaKit stamps the t in
X-MetaKit-Signature: t=...,v1=... once per event, not per attempt, and the
body is serialised once. Every retry is byte-identical: same body, same t,
same v1.
Two consequences.
First, there is no delivery id. Nothing in the headers or the payload says
"this is attempt 3 of the event you saw at attempt 1". If you want a dedupe
key, make one: the v1 HMAC is a fine candidate, since it's a function of
the secret, the timestamp and the exact bytes. Store t:v1 in a table or a
Redis set with a TTL of a day, and drop anything you've seen.
Second, that key isn't enough on its own. A dedupe table protects you against
the same delivery arriving twice. It doesn't protect you against two
genuinely distinct events that mean the same thing to your code, and it
doesn't protect you if the table is wiped. The real fix is making the handler
itself idempotent. For trade events the natural key is data.ticket plus the
event name: "ticket 99302156 is closed" is a fact you can write any number of
times with ON CONFLICT DO NOTHING or an upsert, and the second write
changes nothing.
A small honesty note: two different events with identical bodies in the same
second would collide on t:v1. That can only happen for something like two
identical position.modified events, and if your handler is idempotent the
collision is harmless. Which is the point.
The timestamp reuse also affects your freshness window. A delivery that lands on the fifth attempt is about 30 seconds older than "now". Reject deliveries older than five minutes, not five seconds, or you'll reject your own retries.
Ordering is not guaranteed, so don't replay events
Each event gets its own retry schedule, independent of every other event. Put two together and ordering falls apart.
A scalp opens and closes in one second. The position.opened delivery hits
your handler while it's warming a database connection and times out at 5s.
The position.closed delivery, sent a second after the open, hits a warm
handler and returns 200 in 40ms. Your consumer processes the close, then
half a second later processes the open. If your code does "on opened, insert
row; on closed, update row", the update finds nothing, and then the insert
creates a position that looks open forever.
The tempting fix is to buffer and reorder by timestamp. Don't. You'd need to
know how long to wait, and the answer is "up to 32 seconds, unless the fifth
attempt also failed, in which case forever".
The fix that works: treat events as hints that state changed, not as a
log to replay. When a trade event arrives for account 2, fetch
GET /v1/accounts/2/positions and reconcile your table against it. The API
is the source of truth; the webhook is the doorbell. A handler built this way
is naturally idempotent (fetching twice is harmless), naturally
order-independent (state is state), and naturally recovers from a lost
delivery the next time any event arrives.
You give up a little: a reconcile costs an API call, and for a closed position
you'll want the deal from /v1/accounts/2/deals rather than the (now absent)
position, because in MT5 the close is a deal, not a position (the
orders, deals and positions post
is the primer). You gain a consumer that's correct.
Respond 200 first, work later
Five seconds sounds generous until you list what's in it: TLS handshake, your reverse proxy, framework startup on a cold serverless function, a database connection from a cold pool, the write itself, and the response. A Lambda cold start alone can eat a meaningful chunk of that.
The rule is old and still right: verify the signature, persist the raw delivery somewhere durable, return 200, and do the real work afterwards.
"Somewhere durable" is load-bearing. On a long-running server, a queue or an inbox table is fine. On serverless, a background thread is not, because the platform freezes or kills the process once the response is sent. Write the raw body to a table, a queue, or object storage inside the request, and process it from a worker.
Verify before you persist, so a forged payload never touches your queue. And verify on the raw bytes: the signature covers the body as sent, and re-serialising parsed JSON produces different bytes.
After the fifth failure, the event is gone
This is the part to be clear-eyed about. After five failed attempts MetaKit
stops. The webhook's lastDeliveryStatus becomes failed and
lastDeliveryAt records when. That's visible in the dashboard and from
GET /v1/webhooks. The event itself is not queued anywhere you can reach.
What MetaKit does not do, as of llms.txt today:
- No replay endpoint. You can't ask for "everything since Tuesday".
- No delivery log. You get last status and last time, per endpoint, not a history.
- No per-event subscriptions. Every endpoint receives every event; you filter
by
X-MetaKit-Eventandaccount_id. - No dead-letter queue you can drain.
So your consumer needs a second path to correctness that doesn't depend on
webhooks at all. Two habits cover it. Run a periodic reconcile (every few
minutes) that fetches positions for each account and diffs against your
state, which catches whatever the webhook path dropped. And watch
lastDeliveryStatus: if it flips to failed, something on your side was
down for 30 seconds, and you probably want to know why.
If your handler is already "fetch state and reconcile", the periodic job is the same function on a timer. That's the payoff of building it that way.
A reference consumer
Node, Express, Postgres. Verify, dedupe, persist, ack. A worker does the rest.
import crypto from 'node:crypto';
import express from 'express';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const SECRET = process.env.METAKIT_WEBHOOK_SECRET!;
const app = express();
function verify(raw: Buffer, header: string | undefined, toleranceSec = 300) {
if (!header) return null;
const parts = Object.fromEntries(header.split(',').map((kv) => kv.trim().split('=')));
const { t, v1 } = parts;
if (!t || !v1) return null;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return null;
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${raw}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
return `${t}:${v1}`; // the dedupe key
}
app.post('/hooks/metakit', express.raw({ type: 'application/json' }), async (req, res) => {
const key = verify(req.body, req.get('X-MetaKit-Signature'));
if (!key) return res.sendStatus(400);
// Durable inbox. Duplicate deliveries are a no-op at the database.
await pool.query(
`INSERT INTO webhook_inbox (delivery_key, event, body)
VALUES ($1, $2, $3) ON CONFLICT (delivery_key) DO NOTHING`,
[key, req.get('X-MetaKit-Event'), req.body.toString('utf8')],
);
res.sendStatus(200); // ack; nothing slow above this line
});
app.listen(3000);CREATE TABLE webhook_inbox (
delivery_key text PRIMARY KEY,
event text NOT NULL,
body text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);The worker polls webhook_inbox WHERE processed_at IS NULL, parses the body,
and for any trade.* event fetches the account's positions from the API and
reconciles. It does not trust data in the payload as the final state; it
trusts the API. When it's done it sets processed_at. If the worker crashes
mid-way, the row is still unprocessed and gets picked up again, which is fine,
because reconciling twice is harmless.
Ordering never comes up. Duplicates never come up. A dropped delivery is caught by the next event or the next scheduled reconcile. Every property the sender couldn't give you, the consumer built for itself.
For a small consumer built on exactly this pattern, the Grafana dashboard post stores trade events with a unique constraint and lets Postgres do the deduplication.
The signature function above is the minimum. The
verification post
covers rotation (accept both secrets for a short window after
POST /v1/webhooks/{id}/rotate) and the raw-body pitfalls in each
framework. Build the inbox first.