Skip to content

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:

  1. Sign the raw bytes, before any JSON parsing. Re-serialising changes the bytes and the signature will not match.
  2. Compare in constant time. == on a digest leaks it.
  3. 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, 503 and 504 are treated as retryable. A 400 or 404 from 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 Gone from 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.