Webhooks¶
Speakr can POST a signed JSON envelope to any HTTPS URL when a recording's lifecycle changes. Use it to push events into automation flows (n8n, Make, Zapier), home dashboards, or any service that prefers push over polling GET /api/v1/recordings.
Webhooks are configured per-user from Account settings → Webhooks, or programmatically via the /api/v1/webhooks API.
Event vocabulary¶
| Event | Fired when | Payload data fields |
|---|---|---|
recording.created | A recording row is created (upload arrived) | recording_id, title, file_size, original_filename |
recording.transcription.started | Worker picks up a transcribe or reprocess-transcription job and the audio file is on disk | recording_id, title |
recording.transcription.completed | Transcription job finished successfully | recording_id, title, language, audio_duration_seconds, transcription_duration_seconds |
recording.transcription.failed | Transcription failed permanently (retries exhausted) | recording_id, title, error |
recording.summary.completed | Summary generated successfully | recording_id, title, summarization_duration_seconds |
recording.summary.failed | Summary failed permanently | recording_id, title, error |
recording.events.extracted | Calendar event extraction finished and produced at least one event | recording_id, title, events_count |
recording.updated | Title, participants, notes, summary, meeting_date, is_inbox, is_highlighted, or folder_id changed via PATCH /api/v1/recordings/{id} | recording_id, title, fields_changed (list of strings) |
recording.deleted | Recording removed | recording_id, title |
webhook.test | Synthetic event from the Test button or POST /api/v1/webhooks/{id}/test | reason, webhook_id |
All events listed above fire in the current backend. The recording.transcription.started event fires only after the audio file's existence on disk is confirmed, so subscribers don't see misleading started→failed sequences for jobs that abort immediately (e.g. the audio file was deleted between upload and worker pickup).
The recording.updated event is not currently debounced. Rapid edits (e.g. notes autosave, drag-drop tag changes) emit one event per mutation. Receivers that want to coalesce can deduplicate on (recording_id, fields_changed) within a short window. A built-in debounce is planned for a later release (see docs/roadmap.md).
Envelope¶
Every delivery is a POST with a JSON body shaped like:
{
"id": "f4e6a4e1-3b9b-4a04-9d4f-0e7a5d8b3c10",
"type": "recording.transcription.completed",
"timestamp": "2026-06-04T15:23:11.124Z",
"user_id": 42,
"data": {
"recording_id": 9173,
"title": "Q3 planning",
"language": "en",
"audio_duration_seconds": 3624.7
}
}
Headers:
| Header | Purpose |
|---|---|
Content-Type: application/json | Body format |
User-Agent: Speakr-Webhook/1.0 | Identifies Speakr to receivers |
Speakr-Event | The event type — useful for routing without parsing the body |
Speakr-Delivery-Id | UUID echo of data.id — receivers use it for idempotency |
Speakr-Timestamp | ISO-8601 UTC of dispatch — receivers may reject stale deliveries |
Speakr-Signature | sha256=<hex> HMAC of the raw body with the webhook's secret |
Signature verification¶
Every receiver must verify the Speakr-Signature header before trusting the body. Failure to verify means anyone who guesses the URL can forge events.
Python¶
import hmac
import hashlib
def verify_speakr(secret: str, raw_body: bytes, signature_header: str) -> bool:
if not signature_header.startswith('sha256='):
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
given = signature_header[len('sha256='):]
return hmac.compare_digest(expected, given)
Node.js¶
const crypto = require('crypto');
function verifySpeakr(secret, rawBody, signatureHeader) {
if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const given = signatureHeader.slice('sha256='.length);
try {
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(given, 'hex'));
} catch (_) {
return false;
}
}
Bash (for sanity-checking)¶
Compare the output against the Speakr-Signature header value.
Retry policy¶
Delivery attempts use the following backoff schedule:
| Attempt | Delay before this attempt |
|---|---|
| 1 | immediate |
| 2 | 30 s |
| 3 | 2 min |
| 4 | 10 min |
| 5 | 1 hour |
After attempt 5, status flips to permanent_failure and the webhook's consecutive_failures counter increments. When it reaches WEBHOOK_AUTOPAUSE_FAILURES (default 10) the webhook is auto-paused; the user must manually re-enable it.
Retryable HTTP responses: 408, 429, 5xx, plus network errors and timeouts. Non-retryable: 2xx (success), 3xx (we disable allow_redirects on purpose), 4xx other than 408/429.
A successful delivery (2xx) resets consecutive_failures to 0.
SSRF guard¶
Webhook URLs are validated at save time and again at dispatch time:
- Scheme must be
http://orhttps://.http://is rejected unless the webhook hasallow_http=true. - The hostname is resolved; if any returned address is private (RFC 1918, link-local, loopback, multicast, reserved), the URL is rejected.
- Operators can carve out internal hosts via
WEBHOOK_INTRANET_HOST_ALLOWLIST— a regex matched against the hostname.
This prevents accidentally pointing a webhook at an internal service (metadata endpoints, admin consoles) that should not receive Speakr payloads.
Configuration¶
| Env var | Default | Purpose |
|---|---|---|
WEBHOOK_GLOBAL_ENABLED | true | Admin kill switch. Set to false to disable all dispatch system-wide. |
WEBHOOK_MAX_PER_USER | 10 | Hard cap on webhooks per user. |
WEBHOOK_DELIVERY_TIMEOUT_SECONDS | 10 | Per-attempt HTTP timeout. |
WEBHOOK_MAX_ATTEMPTS | 5 | Retry cap before permanent_failure. |
WEBHOOK_AUTOPAUSE_FAILURES | 10 | Consecutive failures before auto-pause. |
WEBHOOK_DISPATCHER_INTERVAL_SECONDS | 5 | How often the dispatcher polls for due deliveries. |
WEBHOOK_INTRANET_HOST_ALLOWLIST | empty | Regex of allowed private hosts. Empty = SSRF block always applies. |
API surface¶
All endpoints under /api/v1/webhooks require an authenticated session or an API token. The OpenAPI schema documents every field.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/webhooks | List the caller's webhooks. Returns event_types + max_per_user for UI rendering. |
| POST | /api/v1/webhooks | Create a webhook. Response includes the secret once; capture it. |
| GET | /api/v1/webhooks/{id} | Read one. Secret is never returned. |
| PATCH | /api/v1/webhooks/{id} | Update name / url / events / enabled / allow_http. |
| DELETE | /api/v1/webhooks/{id} | Delete (cascades to deliveries). |
| POST | /api/v1/webhooks/{id}/rotate-secret | Generate a fresh HMAC secret. Returned once. |
| POST | /api/v1/webhooks/{id}/test | Queue a synthetic webhook.test delivery. |
| GET | /api/v1/webhooks/{id}/deliveries | Recent deliveries (default 50, max 200). |
| GET | /api/v1/webhooks/{id}/deliveries/{did} | Full delivery record including the original payload. |
| POST | /api/v1/webhooks/{id}/deliveries/{did}/replay | Re-fire the payload as a new delivery. |
Operational notes¶
- The dispatcher runs in a daemon thread inside the Speakr web process. In multi-worker Gunicorn setups, every worker runs its own dispatcher; the dispatcher polls the database with a small batch limit so the total outbound throughput is naturally bounded.
webhook_deliveryrows accumulate over time. There is no automatic pruning yet — operators should run a periodic delete of rows older than a month or two when the table grows large. A future release will add a retention sweep similar to the recording-session cleanup.- Auto-paused webhooks stay in the database with
enabled=false,auto_paused=true. The user re-enables manually after fixing their receiver; that also clears theauto_pausedflag.