Webhooks¶
Instead of polling, register an endpoint and we POST to it when something
happens. Manage endpoints under Settings → Webhooks, or through
/api/v1/teams/{team_id}/webhooks/ with the write:webhooks scope.
Events¶
| Event | Fires when |
|---|---|
submission.created |
Anything arrives, through any door. Still pending |
submission.approved |
A person approves it. This is when it can become public |
submission.rejected |
A person rejects it |
testimonial.created |
A testimonial is created directly (API, MCP, import) |
survey.response.created |
A survey or NPS response is recorded |
nps.low_score |
An NPS answer lands below your threshold |
video.ready |
A transcode finished and the clip is watchable by a moderator |
import.completed |
A CSV import finished |
team.member.added |
Somebody joins the team |
team.invitation.created |
An invitation is sent |
user.profile.updated |
A profile changes |
video.ready is not 'the video is public'
It means the clip is transcoded and a moderator can watch it — there is
something new to review. Becoming public is the approval, which is
submission.approved. If you are building a "new work waiting" notifier,
video.ready is the one you want.
The request¶
POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-SpeedPy-Event: submission.approved
X-SpeedPy-Delivery: 6f1e...
X-SpeedPy-Timestamp: 1756100000
X-SpeedPy-Signature: 9c8b7a...
| Header | Meaning |
|---|---|
X-SpeedPy-Event |
The event name |
X-SpeedPy-Delivery |
A unique id for this delivery — use it to deduplicate |
X-SpeedPy-Timestamp |
Unix seconds, part of the signed message |
X-SpeedPy-Signature |
HMAC-SHA256, hex |
Verifying a delivery¶
The signature is HMAC-SHA256 over "{timestamp}." + raw_body, keyed with your
endpoint's secret.
import hmac, hashlib, time
def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
message = f"{timestamp}.".encode() + body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
# Constant-time: a plain == leaks the digest one byte at a time.
if not hmac.compare_digest(expected, signature):
return False
# Reject anything older than five minutes, or a captured request can be
# replayed for ever.
return abs(time.time() - int(timestamp)) < 300
Three things to get right, and each is a real failure if you do not:
- Sign the raw bytes, before any JSON parsing. Re-serialising changes the bytes and the signature will not match.
- Compare in constant time.
==on a digest leaks it. - Check the timestamp. A valid signature stays valid for ever; the timestamp is what makes a captured request stale.
Retries and delivery semantics¶
- Non-2xx and network failures retry with exponential backoff, up to 8 attempts.
- Only
429,500,502,503and504are treated as retryable. A400or404from your endpoint is taken at face value — we assume you meant it. - Timeouts: 10s to connect, 30s to read. Return quickly and do your work afterwards.
- Redirects are not followed. Register the final URL.
Delivery is at-least-once. A timeout after your handler succeeded looks
identical to a failure from our side, so you may receive the same event twice.
Deduplicate on X-SpeedPy-Delivery and make your handler idempotent.
Answer first, work later
Return 200 as soon as you have stored the payload, then process it out of
band. A handler that does slow work inline will eventually exceed the 30s
read timeout and earn itself retries it did not need.
Inspecting what happened¶
Every attempt is recorded with its status code and response body. Read them in
the dashboard, or over the API with read:webhooks. When an integration
"missed" an event, this is the first place to look — usually the delivery is
there with a 500 from the receiving end.