Skip to main content

Webhooks

Maven sends an HTTP POST to your webhook URL when a payment session completes or fails. This is the recommended way to get payment results back into your system.
Voice and chat widget both use the same webhook. One webhook URL per project, one handler in your code — it processes both channels. Use the caller field to distinguish: it’s the customer’s phone number for voice, and null for chat.

When Webhooks Fire

Webhooks are sent when a session reaches a terminal payment status:
StatusWhen
payment-successCharge was authorized and captured immediately (default)
payment-authorizedAuthorize.net auth-only charge succeeded — funds held but not captured. You settle the auth yourself in your gateway dashboard or via the gateway’s API.
payment-failedCharge declined or gateway error
Webhooks are not sent for expired, cancelled, or abandoned sessions — poll the GET session endpoint for those.

Configuring Your Webhook URL

Set a webhook URL per app in the Maven Dashboard:
  1. Navigate to your app
  2. Go to the Settings tab
  3. Enter your webhook URL (must be HTTPS in production)
  4. Save

Verifying Webhook Signatures

Your webhook URL is public, so anyone could POST a forged event to it. Maven signs every webhook with an HMAC so you can confirm it genuinely came from us and wasn’t altered in transit. Verification is optional but strongly recommended for production.
Signing is backward compatible — it only adds a header. If you don’t verify it, your existing handler keeps working unchanged. Adopt verification whenever you’re ready.

The signing secret

Each app has its own signing secret (format whsec_…). Find it in the Dashboard under App → Settings → Webhook → Signing secret, where you can reveal, copy, and rotate it.
Rotating generates a new secret and invalidates the old one immediately. Update your server with the new value before (or right after) rotating, or signatures will start failing.

The Maven-Signature header

Every webhook request includes:
Maven-Signature: t=1718500000,v1=4f3a9c...e1
PartMeaning
tUnix timestamp (seconds) when we signed the request
v1HMAC_SHA256(secret, "{t}.{raw_body}") as lowercase hex
The timestamp is part of the signed content, so it can’t be altered without breaking the signature — this is what protects you against replay attacks.

How to verify

  1. Read the raw request body — the exact bytes, before any JSON parsing/re-serialization.
  2. Parse t and v1 from the Maven-Signature header.
  3. Compute HMAC_SHA256(secret, "{t}." + raw_body).
  4. Constant-time compare it against v1.
  5. Reject if t is older than your tolerance (e.g. 5 minutes) to block replays.
import hmac, hashlib, time, json
from fastapi import FastAPI, Request, HTTPException

WEBHOOK_SECRET = "whsec_..."  # from the dashboard
TOLERANCE_SECONDS = 300

def verify(raw_body: bytes, signature_header: str) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in signature_header.split(","))
        t, sig = parts["t"], parts["v1"]
    except (ValueError, KeyError):
        return False
    if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
        return False  # too old — possible replay
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(WEBHOOK_SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

app = FastAPI()

@app.post("/webhooks/maven")
async def maven_webhook(request: Request):
    raw = await request.body()  # RAW bytes, not request.json()
    if not verify(raw, request.headers.get("Maven-Signature", "")):
        raise HTTPException(status_code=401, detail="invalid signature")
    payload = json.loads(raw)
    # ... trusted
    return {"status": "ok"}
Sign the raw bytes, not the parsed JSON. {"a":1,"b":2} and {"b":2,"a":1} are equal objects but different bytes, so re-serializing before hashing produces a mismatching signature. Always HMAC the request body exactly as received, and use a constant-time comparison (hmac.compare_digest / crypto.timingSafeEqual).
Use the Test button next to your webhook URL in the dashboard — it sends a signed sample payload so you can validate your verification code end-to-end.

Payload Format

All webhook payloads share the same top-level fields. The processor object varies by gateway and mode — see Processor Fields by Gateway for the full specs.

Voice vs Chat

The payload is almost identical for voice and chat — only one field differs:
  • caller is the customer’s phone number for voice sessions, and null for chat sessions (no phone involved)
Everything else — status, processor, card_brand, card_last4, error codes — is the same. A single webhook handler works for both.
{
  "session_id": "a1b2c3d4-...",
  "status": "payment-success",
  "project": "my-store",
  "environment": "live",
  "amount": 49.99,
  "currency": "USD",
  "mode": "charge",
  "gateway": "stripe",
  "caller": null,
  "card_brand": "visa",
  "card_last4": "4242",
  "processor": {
    "payment_intent_id": "pi_xxx",
    "charge_id": "ch_xxx",
    "card_brand": "visa",
    "card_last4": "4242"
  }
}

Full examples by gateway

{
  "session_id": "a1b2c3d4-...",
  "status": "payment-success",
  "project": "my-store",
  "environment": "live",
  "amount": 49.99,
  "currency": "USD",
  "mode": "charge",
  "gateway": "stripe",
  "caller": "+14155551234",
  "card_brand": "visa",
  "card_last4": "4242",
  "processor": {
    "payment_intent_id": "pi_xxx",
    "charge_id": "ch_xxx",
    "receipt_url": "https://pay.stripe.com/receipts/...",
    "payment_method_id": "pm_xxx",
    "card_brand": "visa",
    "card_last4": "4242"
  }
}

Field Reference

FieldTypeDescription
session_idstringSession UUID
statusstring"payment-success", "payment-authorized", or "payment-failed"
projectstringApp slug
environmentstring"test" or "live"
amountnumberAmount in dollars (e.g., 49.99)
currencystringCurrency code (e.g., "USD")
modestring"charge" or "tokenize"
gatewaystring"stripe", "authorizenet", "braintree", "shift4", or "fiserv"
callerstring | nullCaller phone number in E.164 format (voice only; null for chat)
card_brandstring | nullCard brand (visa, mastercard, amex, etc.)
card_last4string | nullLast 4 digits of the card
processorobject | nullGateway-specific response fields (see below)
errorobject | nullPresent only when status is "payment-failed"

Error Object (failures only)

{
  "error": {
    "code": "card_declined",
    "message": "Your card was declined."
  }
}

Processor Fields by Gateway

The processor object contains different fields depending on the gateway and mode (charge vs tokenize).
Charge mode:
{
  "payment_intent_id": "pi_xxx",
  "charge_id": "ch_xxx",
  "receipt_url": "https://pay.stripe.com/receipts/...",
  "payment_method_id": "pm_xxx",
  "card_brand": "visa",
  "card_last4": "4242",
  "postal_code": "90210",
  "exp_month": 12,
  "exp_year": 2027
}
FieldDescription
payment_intent_idStripe PaymentIntent ID
charge_idStripe Charge ID
receipt_urlStripe-hosted receipt URL
payment_method_idStripe PaymentMethod ID
card_brandCard brand (visa, mastercard, etc.)
card_last4Last 4 digits of the card
postal_codeBilling ZIP/postal code (if collected)
exp_monthCard expiration month (if collected)
exp_yearCard expiration year (if collected)
Tokenize mode:
{
  "payment_method_id": "pm_xxx",
  "cloned_payment_method_id": "pm_yyy",
  "cloned_customer_id": "cus_xxx",
  "card_brand": "visa",
  "card_last4": "4242",
  "postal_code": "90210",
  "exp_month": 12,
  "exp_year": 2027
}
FieldDescription
payment_method_idOriginal Stripe PaymentMethod ID (on Maven’s platform account)
cloned_payment_method_idPaymentMethod cloned to your Stripe account (absent if same as payment_method_id)
cloned_customer_idCustomer created on your Stripe account
card_brandCard brand
card_last4Last 4 digits
postal_codeBilling ZIP/postal code (if collected)
exp_monthCard expiration month (if collected)
exp_yearCard expiration year (if collected)

Handling Webhooks

Your webhook endpoint should:
  1. Return a 200 status code quickly (within 5 seconds)
  2. Process the payload asynchronously if needed
  3. Be idempotent — use session_id to deduplicate
from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/webhooks/maven")
async def maven_webhook(request: Request):
    payload = await request.json()
    session_id = payload["session_id"]
    status = payload["status"]

    if status == "payment-success":
        card_last4 = payload.get("card_last4")
        caller = payload.get("caller")  # phone for voice, None for chat
        processor = payload.get("processor", {})
        # Update your order, send receipt, etc.
        await handle_payment_success(session_id, processor)
    elif status == "payment-failed":
        error = payload.get("error", {})
        await handle_payment_failure(session_id, error)

    return {"status": "ok"}

Best Practices

Return a 200 response immediately and process the webhook asynchronously. Maven uses a 5-second delivery timeout; if your endpoint is slower, the request is treated as failed and retried.
Use the session_id to deduplicate — check if you’ve already processed this session before acting on it.
Confirm the event came from Maven by verifying the Maven-Signature header — see Verifying Webhook Signatures. As an additional check, you can also confirm the session_id belongs to your organization via the API:
GET /v1/sessions/{session_id}
If your endpoint returns a 5xx or times out, Maven retries up to 3 times with exponential backoff (1s, 2s, 4s). A 4xx response is treated as a rejection and is not retried. If all attempts fail the webhook is dropped, so for critical flows also poll the session status as a fallback.