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
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.

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.