Skip to content
Discord

Webhooks

Pass callbackUrl when you initiate a call and v1 POSTs events there as the call progresses.

{
  "metadata": { "internalOrderId": "8842" },
  "event": {
    "type": "call.completed",
    "data": {
      "call": {
        "id": "68f0a1b2c3d4e5f600000900",
        "status": "completed",
        "startedAt": "2026-07-26T09:15:02.000Z",
        "endedAt": "2026-07-26T09:16:38.000Z",
        "durationSeconds": 96,
        "recordingUrl": "https://cdn.miraiminds.co/recordings/68f0….mp3",
        "detailUrl": "https://app.miraiminds.co/calls/68f0…"
      }
    }
  }
}
FieldDescription
metadataExactly the object you sent at initiate. Present on every event.
event.typeSee below.
event.dataEvent-specific. CallEventData for call.* and end-of-call; ActionEventData for action.
stateDiagram-v2
    [*] --> initiate: call placed
    initiate --> in_progress: connected
    initiate --> failed: failed / busy / no-answer
    in_progress --> ended: disconnected
    ended --> completed: processing done
    completed --> end_of_call: analysis ready
    end_of_call --> lifecycle_ended
    failed --> lifecycle_ended
    lifecycle_ended --> [*]
EventFires when
call.initiateQueued and being dialled.
call.in-progressAnswered; the conversation started.
call.endedThe phone connection dropped.
call.completedRecording, transcript and duration processed.
call.timeoutExceeded the maximum duration.
end-of-callAnalysis is complete — summary, success flag, insights, credits.
call.lifecycle-endedFinal event. No further attempts for this call task.
EventFires when
call.failedGeneral connection failure.
call.busyLine busy.
call.no-answerNobody picked up.
call.validation-failedPre-call validation failed.
call.skipSkipped, e.g. a DND number.
call.rescheduledA callback was scheduled; the lifecycle continues.
call.abortedCancelled through the abort API.

retryProtocol on the assistant decides how many attempts follow a failure. call.lifecycle-ended is what tells you the chain is over — that is the event to close your record on, not call.failed.

Carries the analysis plan output and credit usage.

{
  "metadata": { "internalOrderId": "8842" },
  "event": {
    "type": "end-of-call",
    "data": {
      "call": {
        "id": "68f0a1b2c3d4e5f600000900",
        "status": "completed",
        "durationSeconds": 96,
        "recordingUrl": "https://cdn.miraiminds.co/recordings/68f0….mp3"
      },
      "analysis": {
        "success": true,
        "summary": "Customer confirmed the order and chose cash on delivery.",
        "requiredActions": ["create-order", "send-whatsapp"],
        "insights": { "interested": true, "callback_requested": false }
      },
      "credits": { "used": 1.5, "available": 98.5 },
      "report": { "reAttemptCount": 0, "rescheduledCount": 0 }
    }
  }
}
FieldDescription
analysis.successThe boolean your successCriteriaPlan returned.
analysis.summaryOutput of your summaryPlan.
analysis.requiredActionsPost-call actions the analysis asked for.
analysis.insightsStructured fields from your callInsightPlan.
credits.used / .availableCredits consumed by this call, and the balance after.
reportAttempt counters for this call task.

Fired when the conversation produced a business action for you to execute.

{
  "event": {
    "type": "action",
    "data": {
      "action": "create_order",
      "call": { "id": "68f0a1b2c3d4e5f600000900" },
      "reason": null,
      "payload": {
        "email": "jane@example.com",
        "phone": "+919876543210",
        "lineItems": [{ "title": "Sample Product", "quantity": 1 }],
        "shippingAddress": {
          "firstName": "Jane", "lastName": "Smith",
          "address1": "12 MG Road", "city": "Bengaluru",
          "province": "Karnataka", "zip": "560001", "country": "India",
          "phone": "+919876543210"
        },
        "cartTotal": 1895,
        "codCharge": 50,
        "discount": { "code": "SAVE10", "reason": "abandoned cart recovery" },
        "note": "Customer confirmed on call",
        "abandonedCheckoutId": "gid://shopify/AbandonedCheckout/66509168181329"
      }
    }
  }
}
actionMeaning
create_orderPlace the order described in payload.
send_whatsappSend a WhatsApp follow-up to payload.number.
mark_prepaidThe customer chose prepaid.
update_addressThe customer gave a corrected address.
confirmed_addressThe customer confirmed the address on file.

reason is set when the action exists because something was missing: missing_address, missing_first_name, invalid_cart_data.

Every delivery carries two headers:

HeaderValue
x-signatureHex HMAC-SHA256 of the raw request body, keyed with your organization’s private key
x-public-keyYour organization’s public key, so you know which secret to verify with

There is no timestamp and no replay window in v1. Recompute the HMAC over the raw bytes and compare in constant time.

import hashlib
import hmac
import os

from flask import Flask, request

app = Flask(__name__)
PRIVATE_KEY = os.environ["MIRAI_PRIVATE_KEY"]  # sk_…, the same key you send as x-private-key


def verify_v1(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")


@app.post("/webhooks/call-events")
def call_events():
    if not verify_v1(request.get_data(), request.headers.get("x-signature", ""), PRIVATE_KEY):
        return {"error": "invalid signature"}, 401

    body = request.get_json()
    event_type = body["event"]["type"]
    call = body["event"]["data"].get("call", {})
    handle(event_type, call, body.get("metadata", {}))
    return "", 200
  1. Use the raw body. Sign the exact bytes received, before any JSON parsing. Re-serialising changes key order and spacing, and the digest with it.
  2. Constant-time compare. hmac.compare_digest / crypto.timingSafeEqual.
  3. Keep the private key out of the browser. It signs webhooks and authenticates API calls.
  4. Return 200 fast. Enqueue the work; do not do it inline.
  5. Expect duplicates. v1 events carry no event ID — dedupe on event.type + call.id, and make handlers idempotent.
SymptomCause
Signature never matchesBody was parsed before hashing, or a proxy re-encoded it
Headers missingA reverse proxy or WAF stripped x-signature
Wrong keyx-public-key identifies which organization’s secret to use — check it matches the one you have