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 |
survey.followup.answered |
A respondent answered the AI follow-up on a low score — the payload carries score, question and answer |
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.
- A
410 Gonefrom your endpoint deactivates it — we assume the subscriber is gone for good (a deleted Zap does this) and stop delivering, rather than pile up one failed attempt per event forever.
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.
Webhooks are the API — Pro plan and above¶
Creating and receiving webhooks needs a plan that includes API access (Pro and above). This is enforced on every request and, importantly, at delivery time: if a team is downgraded, its endpoints stop delivering the same day.
When an endpoint stops delivering¶
Deliveries fail closed — we send only while the connection behind the endpoint is still valid. An endpoint stops when:
- the team loses API access (downgrade) or is deactivated;
- the member who created it (through the API or an OAuth app like Zapier) leaves the team or their access expires;
- the OAuth connection it was created through is revoked or expires (idle connections expire after 90 days);
- your endpoint answers
410 Gone, or you delete it.
Endpoints created from the dashboard are grandfathered and keep working as long as the team is entitled. A nightly job deactivates endpoints whose connection is gone, so the webhooks page shows the truth.
There is a limit of 50 active endpoints per team.
Delivery-log retention¶
Every attempt is stored so you can inspect it, but delivery logs can contain personal data (a follow-up answer, for example). So we redact a log's payload and response body 30 days after the attempt, and delete the row outright after 90 days. A redacted delivery can no longer be retried — its payload is gone.