Sections

MeetWave API · 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"
  }
}
EventMeaning
transcription.completedThe transcript is ready to fetch.
transcription.failedIt 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.

HeaderTypeMeaning
webhook-idevt_…The event id. The same on every retry: use it to skip duplicates.
webhook-timestampUnix secondsWhen this attempt was sent.
webhook-signaturev1,<base64>The HMAC-SHA256 of the id, the timestamp and the raw body, keyed with your secret.
content-typeapplication/jsonThe body is JSON, UTF-8.
user-agentMeetWave-Webhooks/1Lets 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 "", 204

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