Sections
Batch
Webhooks
Get a signed request when a transcription completes or fails, and verify its signature before you trust it.
What arrives
Pass webhook_url when you create a transcription. When it completes or fails, MeetWave sends a POST there with a short JSON body. The body never holds the transcript: fetch it with GET /v1/transcriptions/{id}, so a webhook that leaks leaks nothing but an id.
{
"type": "transcription.completed",
"created_at": "2026-10-21T12:00:41Z",
"data": {
"id": "tr_8k2m4q7w1x9c3v5b6n0z2p4r7t",
"status": "completed"
}
}| Event | Meaning |
|---|---|
transcription.completed | The transcript is ready to fetch. |
transcription.failed | It stopped; GET the transcription for error.code. Nothing was charged. |
Headers
The headers follow the Standard Webhooks format, so its libraries can check them too.
| Header | Type | Meaning |
|---|---|---|
webhook-id | evt_… | The event id. The same on every retry: use it to skip duplicates. |
webhook-timestamp | Unix seconds | When this attempt was sent. |
webhook-signature | v1,<base64> | The HMAC-SHA256 of the id, the timestamp and the raw body, keyed with your secret. |
content-type | application/json | The body is JSON, UTF-8. |
user-agent | MeetWave-Webhooks/1 | Lets you tell these requests apart in your logs. |
Your signing secret
Each account has one secret, created the first time you read it. It looks like whsec_MfKQ…. Keep it on your server next to the API key. Rotating it replaces it at once: from then on every webhook is signed with the new one.
# Read the secret (created on the first call)
curl https://api.meetwave.io/v1/webhook-secret \
-H "Authorization: Bearer $MEETWAVE_API_KEY"
# Replace it: the old secret stops signing at once
curl -X POST https://api.meetwave.io/v1/webhook-secret/rotate \
-H "Authorization: Bearer $MEETWAVE_API_KEY"Both answer with the secret:
{
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}Verify the signature
Check every webhook before you trust it. Take the raw body exactly as it arrived — before any JSON parsing — and compute the HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body> with the part of the secret after whsec_, base64-decoded, as the key. The signature header holds one or more v1, entries separated by spaces; accept the event when one of them matches. Reject a timestamp more than 5 minutes away from your clock: that stops an old event from being replayed.
import base64
import hashlib
import hmac
import time
def verify_webhook(secret, headers, raw_body, tolerance_seconds=300):
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
return False
if abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False
# Flask: verify the raw body before parsing it
import os
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/hooks/meetwave")
def meetwave_webhook():
if not verify_webhook(os.environ["MEETWAVE_WEBHOOK_SECRET"], request.headers, request.get_data()):
abort(400)
event = request.get_json()
print(event["type"], event["data"]["id"])
return "", 204Both functions are checked against the signing test vector of the Standard Webhooks specification.
Answer, retries and duplicates
Answer with any 2xx within 10 seconds; do the slow work after you answer. Any other answer, a redirect or no answer counts as a miss. MeetWave makes 3 attempts in all — immediately, after 5 seconds, after 30 seconds — and then stops. The address must be https on a public host; redirects are not followed.
The same event can arrive twice (your answer was lost, say), so keep the webhook-id values you handled and skip a repeat. How delivery went is on the transcription's webhook field: status, attempts and last_status_code. If every attempt missed, nothing is lost: the result is still there for polling.