Polling GET /v1/jobs/:id in a loop is fine for a throwaway script. It’s a bad fit for a pipeline where losing a transcript is not an option.
Webhooks flip the model: when the job finishes, we POST the transcript straight to your server. Three reasons to set one up:
- Stop polling. Your app no longer asks “are you done yet?”
- Can save you money. Our Core model with deferred mode is only $0.00075/min (50% off standard). Webhooks are the usual way to receive those jobs without polling.
- Required on Metadata-only workspaces. For those, we delete the transcript when your webhook sends us the ok. Save it first, or it’s gone.
Quick Overview
Five rules prevent almost every webhook bug:
- Register your endpoint in Dashboard → Webhooks. Nothing webhook-related is configured in the SDK.
- Verify signatures against the raw request body (before parsing the JSON.)
- During secret rotation, pass both secrets:
[active, previous]. - Save the transcript before you return 200. If the write fails, return 500 (or any non-2xx) so we retry. On Metadata-only workspaces, 200 means “you have it, we’re deleting it.”
- Deduplicate on
id. Delivery is at-least-once, so duplicates can happen.
What you’ll receive
Each endpoint subscribes to job.completed, job.failed, and/or ping in Dashboard → Webhooks.
Job events arrive with object: "job". Branch on status (completed, or failed), and use type to identify the kind of work (e.g. transcription).
{
"object": "job",
"id": "job_01J7XYZ9876543210ABC",
"status": "completed",
"type": "transcription",
"transcript": "Good morning and welcome to the quarterly earnings review...",
"duration": 1842.15,
"model": "core",
"service_mode": "deferred",
"language": "en",
"error": null,
"created_at": "2026-08-18T14:00:12.000Z",
"completed_at": "2026-08-18T14:04:30.000Z"
}
ping events are connectivity checks from the dashboard. Return 200 and ignore.
Verifying webhook signatures
Every request carries a SpeechWeave-Signature header:
SpeechWeave-Signature: t=1755527400,v1=9f86d081884c7d65...
The signature is an HMAC-SHA256 digest over the timestamp and raw payload:
HMAC-SHA256(secret, t + "." + raw_body)
Three rules, listed in the order they’ll save you the most pain:
- Verify the raw bytes. A common cause of “invalid signature” errors is parsing beforehand. In Express, that means something like
express.raw({ type: "application/json" }). - Reject stale timestamps. If
|now − t|is greater than 300 seconds, the request is a replay so 401 it. (Or it could mean your server clock is drifting.) - Compare in constant time. Use the SDK helpers or a constant-time compare for best practices.
SDK helpers
Node.js (@speechweave/node):
import { verifyWebhook } from "@speechweave/node";
const result = verifyWebhook(
[
process.env.SPEECHWEAVE_WEBHOOK_SECRET!,
process.env.SPEECHWEAVE_WEBHOOK_PREVIOUS_SECRET!,
].filter( Boolean ),
rawBodyString,
req.headers["speechweave-signature"] ?? ""
);
if ( ! result.ok ) throw new Error( "Invalid signature" );
Python (speechweave):
from speechweave import verify_webhook
result = verify_webhook(
secret = [
s for s in (
os.environ.get("SPEECHWEAVE_WEBHOOK_SECRET"),
os.environ.get("SPEECHWEAVE_WEBHOOK_PREVIOUS_SECRET"),
) if s
],
raw_body = raw_body_str,
signature_header = request.headers.get( "SpeechWeave-Signature", "" ),
tolerance_sec = 300,
)
if not result["ok"]:
raise HTTPException( status_code = 401, detail = f"Invalid signature: {result.get('reason')}" )
Rotating secrets
When you rotate a secret in Dashboard → Webhooks:
- The new secret signs requests immediately.
- The old one stays valid for 24 hours. During that window, requests may be signed with either key (or carry multiple
v1=values). - Keep both working by passing an array
[activeSecret, previousSecret]to the verifier, so no in-flight request dies during a deployment.
Leaked the old one? Don’t wait out the 24 hours. Open the endpoint menu → Revoke immediately. We’ll stop signing with it right away.
Save before you ack (to prevent data loss)
This section matters most on Metadata-only workspaces, but the pattern is good hygiene on every plan.
Standard workspaces: transcripts live in job records for about 30 days, and delivery logs hold payloads for 72 hours (30 days with extended debug). Dashboard replay works while the stored payload still contains transcript text.
Metadata-only workspaces: once every subscribed endpoint returns 2xx, we scrub the transcript and prompt from delivery logs and from the live job record. After that, it exists only where you stored it.
SpeechWeave Your Webhook Receiver Your Database
| | |
|--- POST job (completed) --------->| |
| |--- Write transcript -------->|
| |<-- Confirm write ------------|
| | |
|<-- 200 OK (DB write succeeded) ---| |
| (transcript scrubbed) | |
| | |
|<-- 500 (DB write failed) ---------| |
| (payload kept for retry) | |
Two rules in practice:
- Commit first, then 200. Write
event.transcriptto your database, S3, or queue before acknowledging the request. - Return 500 if the write fails. A non-2xx tells us the delivery failed, so we keep the unscrubbed payload queued for retry. A 200 on a failed write is irreversible data loss on metadata-only accounts.
Retries & duplicates
Delivery is at-least-once. If your endpoint returns non-2xx or times out, we retry with exponential backoff across 5 attempts:
| Attempt | Delay after previous | Cumulative elapsed |
|---|---|---|
| 1 | Immediate | 0m |
| 2 | 5 minutes | 5m |
| 3 | 30 minutes | 35m |
| 4 | 2 hours | ~2h 35m |
| 5 | 12 hours | ~14h 35m |
After attempt 5, we stop. Failed delivery logs stay visible in your dashboard for 7 days; on metadata-only accounts, the stored payload is scrubbed once attempts are exhausted.
Slow handlers can also produce duplicates (a retry lands while your first attempt is still finishing). Treat them as normal: make the top-level id (e.g., job_01J7...) a unique key and upsert, or track processed IDs.
A production-ready handler
Complete Express handler with raw-body parsing, rotation-aware verification, upsert deduplication, and safe persistence:
import express from "express";
import { verifyWebhook } from "@speechweave/node";
import { db } from "./database";
const app = express();
app.post(
"/api/webhooks/speechweave",
express.raw({ type: "application/json" }),
async ( req, res ) => {
const rawBody = req.body.toString( "utf8" );
const signature = req.header( "SpeechWeave-Signature" ) ?? "";
// 1. Verify signatures (accepts active + rotated secret)
const verification = verifyWebhook(
[
process.env.SPEECHWEAVE_WEBHOOK_SECRET!,
process.env.SPEECHWEAVE_WEBHOOK_PREVIOUS_SECRET ?? "",
].filter( Boolean ),
rawBody,
signature
);
if ( ! verification.ok ) {
return res.status( 401 ).send( "Unauthorized: Invalid signature" );
}
const event = JSON.parse(rawBody);
// 2. Dashboard ping check
if ( event.type === "ping" ) {
return res.status( 200 ).json({ received: true });
}
// 3. Process job records
if ( event.object === "job" ) {
try {
// Save-Before-Ack: persist the transcript before returning 200
await db.transcriptionJobs.upsert({
where: { speechweaveId: event.id },
update: {
status: event.status,
transcript: event.transcript,
durationSeconds: event.duration,
errorMessage: event.error ?? null,
completedAt: event.completed_at ? new Date( event.completed_at ) : null,
},
create: {
speechweaveId: event.id,
status: event.status,
transcript: event.transcript,
durationSeconds: event.duration,
errorMessage: event.error ?? null,
completedAt: event.completed_at ? new Date( event.completed_at ) : null,
},
});
// 4. 200 tells SpeechWeave it can scrub metadata (metadata-only workspaces)
return res.status( 200 ).send( "OK" );
} catch ( error ) {
console.error( "DB write failed; triggering webhook retry for job:", event.id, error );
// 5. 500 tells SpeechWeave to retry using the backoff schedule
return res.status( 500 ).send( "Database error" );
}
}
return res.status( 200 ).send( "Ignored event type" );
}
);
app.listen( 8080 );
FAQ
Why do I get duplicate deliveries?
At-least-once delivery means a retry can land after your first handler already succeeded. Deduplicate on the job id.
Signature verification keeps failing. Check in order: (1) you’re verifying the raw body, not the parsed one. (2) your server clock is within 300 seconds of real time. (3) you hold the right secret. During rotation, verify against both.
Is my transcript gone if my endpoint was down? Standard workspace: No, the job record keeps the transcript for ~30 days, and replay works while the payload still contains it. Metadata-only: the payload survives the retry window (~14h 35m total), then it’s scrubbed.
Where do I change the URL or subscribed events? On your Dashboard → Webhooks page.