Skip to content
QuickSamvaad

Developers: API keys, webhooks and idempotency

Create scoped API keys, call the public REST API safely with Idempotency-Key, receive signed webhooks, verify their signatures, and handle retries and redelivery.

Reading time
6 min
Last updated
Applies to
QuickSamvaad web app

Connect QuickSamvaad to your own systems: send order updates from your store, add leads from your website, or push incoming messages into your CRM. Owners, admins and the Developer role manage keys and webhooks in Settings → Developer. Tools such as Make or Pabbly Connect can use the same API keys and webhooks.

API keys

  1. Create a key

    In Settings → Developer, create a key, name it after the system that uses it, and tick only the scopes it needs.

  2. Copy it once

    The full key is shown only once. Store it in your server's secret manager. We keep only a fingerprint, so we can't show it again.

  3. Send it as a bearer token

    Add Authorization: Bearer <key> to every request. Keys start with epk_live_ in production.

ScopeAllows
channels:readRead your connected numbers
contacts:read / contacts:writeRead contacts / create, update and record consent
templates:readList templates and their status
campaigns:read / campaigns:launchRead a campaign / launch one
inbox:read / inbox:replyRead message status / send messages
  • You can only grant scopes you hold yourself. A Developer can create read-only keys; sending needs someone with reply access.
  • Each key allows 20 requests a second by default; over that you get 429 with a Retry-After header.
  • Optionally restrict a key to your servers' IP addresses, and give it an expiry date.
  • Rotate a key to get a new one with the same settings; the old one keeps working for a grace period you choose (a day by default) so you can deploy without downtime. Revoke stops a key immediately.
  • Team, billing and settings can't be reached with an API key, only by people signed in.

The public API

The base URL is https://api.quicksamvaad.com/public/v1. Every endpoint applies the same rules as the app: the 24-hour window, marketing consent, Meta's per-person cooldown and template approval. A machine-readable OpenAPI description is at /public/v1/openapi.json.

Method and pathScopeDoes
POST /messagesinbox:replySend a message inside the 24-hour window. Needs Idempotency-Key.
POST /messages/templateinbox:replySend an approved template. Needs Idempotency-Key.
GET /messages/{id}inbox:readStatus, WhatsApp message id, error and pricing.
GET /contacts, POST /contactscontacts:read / writeList contacts; create or update one by phone number.
GET /contacts/{id}, PATCH /contacts/{id}contacts:read / writeRead or update one contact.
POST /contacts/{id}/consentscontacts:writeRecord an opt-in or opt-out in the consent history.
GET /templatestemplates:readFilter by status, category, language or name.
GET /campaigns/{id}campaigns:readA campaign and its progress.
POST /campaigns/{id}/launchcampaigns:launchLaunch a prepared campaign. Needs Idempotency-Key.
Send a template
curl -X POST https://api.quicksamvaad.com/public/v1/messages/template \
  -H "Authorization: Bearer $QUICKSAMVAAD_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"phone_number_id": "<your number id>", "to": "+919812345678",
       "template": {"name": "order_update", "language": "en", "components": []}}'

Errors use the standard problem format (RFC 7807) with a stable code field, such as window_closed, send_rejected or rate_limited, so your code can branch on it.

Idempotency: safe retries

Requests that send messages or launch campaigns require an Idempotency-Key header: a unique value, such as a UUID, that you generate once per intended action. If the network drops and you retry, use the same key.

You sendYou get
A new key202 Accepted: the message is queued.
The same key and the same body again200 OK with the original message. Nothing is sent twice.
The same key with a different body409 Conflict (idempotency_conflict).
No key400 (idempotency_key_required).

Webhooks

Add an endpoint in Settings → Developer with an https URL and the events you want (or all of them). You get a signing secret once; keep it with your API secrets. Use Send test to queue a test event to that endpoint.

EventSent when
message.receivedA customer sends you a message. Includes the message and the contact.
message.statusAn outgoing message is sent, delivered, read or fails.
conversation.updatedA conversation's status, window or unread count changes.
conversation.assignedA conversation is assigned, claimed or handed off.
channel.alertA number is paused or has a connection problem.
What a delivery looks like
POST https://your-server.example/quicksamvaad
Content-Type: application/json
User-Agent: QuickSamvaad-Webhooks/1.0
X-Epik-Webhook-Id: 0193f1c2-...        same on every retry: dedupe on it
X-Epik-Event: message.status
X-Epik-Delivery-Id: 0193f1c3-...
X-Epik-Delivery-Attempt: 1
X-Epik-Signature: t=1794787200,v1=5f1c...

{"id": "0193f1c2-...", "type": "message.status", "api_version": "2026-11-12",
 "created_at": "2026-11-12T10:00:00Z", "workspace_id": "...", "data": {...}}

Reply with any 2xx status within 10 seconds, then do the work in the background.

Verify the signature

Every delivery is signed so you can be sure it came from QuickSamvaad and wasn't changed. X-Epik-Signature holds a timestamp t and one or more v1 signatures: the HMAC-SHA256 of "<t>." + raw body, in hex, keyed with your endpoint's signing secret.

  1. Read the raw request body before any JSON parsing. Re-serialised JSON won't match.
  2. Reject the request if the timestamp is more than five minutes old.
  3. Compute the HMAC and compare it to each v1 value in constant time; accept if any matches.
Python
import hashlib, hmac, time

def verify_signature(secret: str, header: str, raw_body: bytes, tolerance_s: int = 300) -> bool:
    parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
    ts = next((v for k, v in parts if k == "t"), None)
    sigs = [v for k, v in parts if k == "v1"]
    if ts is None or not ts.isdigit() or not sigs or abs(time.time() - int(ts)) > tolerance_s:
        return False
    expected = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)
Node.js
const crypto = require("crypto");

// Use the raw request body, e.g. express.raw({ type: "application/json" }).
function verifySignature(secret, header, rawBody, toleranceS = 300) {
  const parts = header.split(",").map((p) => p.split("="));
  const ts = parts.find(([k]) => k === "t")?.[1];
  const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!ts || !/^\d+$/.test(ts) || !sigs.length) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceS) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(Buffer.concat([Buffer.from(ts + "."), rawBody])).digest("hex");
  return sigs.some((s) => s.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

Retries, ordering and redelivery

  • At least once. An event can arrive more than once. Store X-Epik-Webhook-Id and ignore ids you've already processed.
  • No ordering guarantee. Events can arrive out of order. Use created_at, and remember message statuses only move forward: sent, then delivered, then read.
  • Retries. A timeout, a 408, 425, 429 or a 5xx response is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. Other 4xx responses and redirects are not retried.
  • Turned off after failures. After 50 failed attempts in a row the endpoint is turned off. Fix it, turn it on again, then redeliver from the delivery log.
  • Delivery log. Every attempt is listed with its status, response time and error for 30 days. Redeliver sends an event again with the same webhook id, so your deduplication still works.

Common questions

Is there a Zapier app?
Not as a native app. Use Zapier, Make or Pabbly Connect with their generic webhook and HTTP steps together with an API key.
Can I trigger a campaign from my system?
Yes. Create and prepare the campaign in the app, then launch it with POST /campaigns/{id}/launch and an Idempotency-Key.
Why was my send rejected with 409?
A guard refused it: the window is closed (send a template), the contact has no marketing consent, is resting after Meta's limit, or the template isn't approved. The response's code and reasons say which.

Was this helpful?

If something was missing, unclear or wrong, tell us. We update these guides from what you send.

Send feedback