Webhooks
Be told when an item lands or expires, when an erasure completes, and when an end user is deleted, with a signed POST you can verify.
A webhook endpoint is a URL of yours that receives a signed POST for each event it listens for. Add one in
the console under Webhooks. An endpoint belongs to one project and hears only that project's end users; a
project keeps up to five, and each is sent every event it listens for.
Events
| Event | Sent when | data carries |
|---|---|---|
item.landed | Added items are stored and searchable by their words. | items: each one's item, event_id, kind |
item.expired | Items reached their expires and every copy was erased. | items (their ids), erased_at |
erasure.completed | A forget finished. | asked, items, receipt (rows removed per table) |
end_user.deleted | A whole end user was deleted from the console. | bytes |
webhook.test | A member pressed Send test event. Sent whatever you listen for. | endpoint, by |
item.read, for when an item has been read into records, is listed in the console and not sent yet.
One item.landed covers every item one add landed, up to 500, and one item.expired every item one expiry pass
erased: an import of thousands of items is tens of events, not thousands.
An event carries ids and counts, never an item's words. Fetch the item with your key if you need its text.
The request
POST /hooks/twin-memory HTTP/1.1
Content-Type: application/json
Twin-Event-Id: evt_9c2f41d07be3a1e5c4f0b2d8
Twin-Event-Type: item.landed
Twin-Delivery-Attempt: 1
Twin-Signature: t=1792591572,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_9c2f41d07be3a1e5c4f0b2d8",
"type": "item.landed",
"created": "2026-10-21T14:06:12.204Z",
"project": "production",
"end_user": "user_42",
"data": {
"items": [
{ "item": "msg-001", "event_id": "ev_620270db0bff9328", "kind": "message" },
{ "item": "msg-002", "event_id": "ev_8a51c09e2d7b4f13", "kind": "message" }
]
}
}end_user is the id you send as X-Twin-Account. It is null on a test.
Verify the signature
Twin-Signature holds a Unix time t and v1, the hex HMAC-SHA256 of <t>.<body> keyed with the
endpoint's signing secret (whsec_…). Verify before you parse, over the exact bytes received, and refuse a
t more than five minutes from your clock so a captured request cannot be sent again later.
import hashlib
import hmac
import time
def verified(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Answer, retries and duplicates
Answer 2xx within 15 seconds. Anything else, no answer, or a redirect is a failed attempt, retried after
30 s, 2 min, 10 min, 1 h, 3 h, 6 h, 8 h and 8 h: nine attempts over about 26 hours. After the last the
delivery is marked failed, and the endpoint shows as failing until a delivery gets through.
Delivery is at least once. A retry, a replay from the console, or a delivery cut off mid-send can bring the
same event again, under the same Twin-Event-Id. Keep the ids you have handled and skip one you have seen.
Events are not ordered: use created when order matters. An item erased before its item.landed was
sent is reported by erasure.completed alone.
Do the work after you answer. A receiver that writes to a queue and answers at once never times out.
Managing an endpoint
- Send test event sends
webhook.testto that endpoint alone. - Replay sends a logged delivery again, under the same event id, starting the retry schedule over.
- Rotate secret creates the new secret and signs with it at once, retries included, and the old one stops verifying. Put the new secret on the receiver right after. A delivery that fails verification in between should be answered with a non-2xx status: it is retried on the schedule above, so nothing is lost.
- Disable stops new events being queued for the endpoint, and holds the deliveries already queued until it is enabled again. Delete removes the endpoint and its log.
Endpoints must be HTTPS and reach a public address. The delivery log keeps 30 days. An endpoint added to a project that had none starts hearing events within about 30 seconds.