Fixtures and how the numbers were made
Everything here is invented. The signing key demo-signing-key-0001 is a made-up string for this example and protects nothing. The server's clock reads 1760000300 seconds. The body is a short invented event with the event ID EvSYNTH0001. Each signature below was computed with a standard HMAC-SHA256 routine over the string v0, a colon, the timestamp, a colon and the body, then prefixed with v0=. These are test vectors for your own implementation, computed independently of any Slack code; no Slack request was made.
- Body: {"type":"event_callback","event_id":"EvSYNTH0001","team_id":"TSYNTH01","event":{"type":"message","text":"hello"}}
- Case D alters the last letter of the text from o to O after signing.
- Case E signs with a timestamp 5000 seconds earlier than the one sent.
If the matrix is wider than the box, scroll horizontally to read every column. Keyboard: focus the matrix and use Left/Right.
server time = 1760000300
A genuine ts=1760000290 sig=v0=cab8cd78dd3da47be7f322e682c03e44f87230792feb436f981596885f24ff6d
B genuine, 300 s ts=1760000000 sig=v0=e92c0b417e36fff1efd69fc3101e024c8b24b95da8a3643631296fe63e1f5774
C genuine, 301 s ts=1759999999 sig=v0=ba3b60daa1291f0a3d57795d619735fe9d634a8bc1fe84c0762f15b911c34f97
D body altered ts=1760000290 sig=v0=cab8cd78dd3da47be7f322e682c03e44f87230792feb436f981596885f24ff6d
E ts mismatch ts=1760000290 sig=v0=ba8c9c542133664c2086443cde0d994fb95075fb5ddd148db3af6ec3a844c691Expected decisions
A is accepted: the signature matches and the timestamp is ten seconds old. B is accepted under this example's reading that exactly 300 seconds is not more than five minutes; the documentation says to drop requests more than five minutes away, so the boundary is a choice to state in your own tests. C carries a genuine signature but is 301 seconds old, so it is rejected for age. D uses A's signature with a changed body and must fail the signature. E has a signature computed over a different timestamp and must fail.
- A: accept, start the work once.
- B: accept at the boundary under the stated reading.
- C: reject, stale.
- D: reject, signature mismatch.
- E: reject, signature mismatch.
Repeated and simultaneous events are cases six to eight
Deliver case A a second time, still inside the window, as an attacker's replay would; a Slack retry of the same event is delivered again too. The signature and timestamp are both valid, so the verifier alone cannot reject it. The endpoint must recognise EvSYNTH0001 as already claimed, reply with success and not start the work again. The time window limits how long a captured request is useful; the event ID is what prevents the work from running twice.
The order of the steps matters. Slack sends its first retry nearly immediately after a missed three-second reply, so a retry can arrive while the first job is still running. If the event ID is recorded only when the work finishes, that retry looks new and a second job starts. The safe order is: verify the signature and timestamp, claim the event ID with one atomic insert (a unique key, state received), queue the job from that record, reply 2xx, and let the job mark the row done. Case six is the sequential repeat. Case seven is two copies of case A arriving at the same moment: exactly one insert succeeds, one job is queued, and both requests get a success reply. Case eight is a worker stopped after the claim: the row stays received, and a sweep re-queues it after a timeout longer than the job's longest run.
- Claim the event ID atomically before queueing the work, and only after verification passes, so a forged request cannot use up a genuine ID.
- Mark it done when the job finishes; re-queue a claim still received after the agreed timeout.
- A worker that dies after doing the work but before marking it done will be re-run, so the action should be safe to repeat; the claim alone is not exactly once.
- A second request with the same ID but a different body is worth logging as an anomaly.
- A reply is still required within Slack's time limit.
If the matrix is wider than the box, scroll horizontally to read every column. Keyboard: focus the matrix and use Left/Right.
Illustrative timeline (figures invented, a job that takes 5 seconds)
Recorded when the work finishes (race):
t=0.0 s delivery 1 arrives, EvSYNTH0001 not recorded, job 1 starts
t=3.0 s no reply yet, Slack counts the delivery as failed and retries
t=3.0 s delivery 2 arrives, EvSYNTH0001 still not recorded, job 2 starts
t=5.0 s job 1 finishes and records the ID; job 2 also runs
-> the work ran twice
Claimed before queueing (unique key, state received):
t=0.0 s delivery 1 arrives, insert succeeds, job queued, 200 sent
t=3.0 s delivery 2 arrives, insert fails on the unique key, 200 sent, nothing queued
t=5.0 s job 1 finishes, row set to done
-> the work ran onceWhat was and was not exercised
Only the arithmetic was exercised: the five signatures were recomputed with a standard HMAC-SHA256 routine and match, and the decisions follow from the stated rules. The timeline in the repeated-events section is an invented illustration of ordering, not a recorded run. No endpoint, framework, queue, database or Slack workspace was run, and nothing here shows that any particular handler behaves correctly. Use the vectors and the three repeat cases to test your own. The single events receiver outcome is priced at a fixed £445 as an untested test price, with payment after the agreed checks pass and you sign off; never send a real signing secret in an enquiry.
Sources and limits
- Slack: verifying requests from Slack Checked 2026-10-11.
- The base string is v0, the timestamp and the raw body joined by colons; the signature is v0= followed by the HMAC-SHA256 hex digest with the signing secret.
- Requests whose timestamp is more than five minutes from local time should be dropped as possible replays.
- Slack: Events API Checked 2026-10-11.
- event_id is globally unique; failed deliveries are retried up to three times, the first retry nearly immediately, with x-slack-retry-num and x-slack-retry-reason headers.