WidersApps API
A REST API for your WidersApps data — contacts, conversations, orders, and bookings. JSON in, JSON out, one uniform envelope, secured with a bearer key you create yourself.
Base URL
Every endpoint lives under a single, versioned base path. Send all requests over HTTPS.
https://console.widers.net/api/v1
Resources
The core resources and the scope each needs. This is a curated starting set — the interactive Reference lists every endpoint the API exposes, with the full request and response of each.
| Resource | What you can do | Scope |
|---|---|---|
| Contacts | List · view · create · update · delete | contacts.view · contacts.manage |
| Products | List · view · create + update core fields (name, price, status, stock — for catalog/inventory sync) | commerce.view · commerce.manage |
| Orders | List · view · create · advance status | commerce.view · commerce.manage |
| Messages | List · view · send | inbox.access |
| Conversations | List · view · assign · transfer · close · reopen · read · mute · archive · follow · note · tag | inbox.access |
| Bookings | Full CRUD + availability | calendar.view · calendar.manage |
| Tags | List tags; create, rename and delete your own (system tags are read-only) | inbox.access · tags.manage |
| Media | Upload a file and get a URL to attach when sending a message | inbox.access |
| Quick replies | List and create saved quick replies | quick-replies.manage |
| Campaigns | List broadcasts with their stats; pause, resume or cancel a running one | campaigns.view · campaigns.manage |
Message delivery, read and failed statuses arrive as webhooks (message.delivered · message.read · message.failed) rather than as pollable endpoints. Conversation lifecycle changes (created · assigned · closed · tagged · …) are webhooks too — subscribe under Settings → Webhooks.
Create an API key
Create a key in your dashboard under Settings → Developers → API keys (Owner-only). When you create one you choose:
- A name — so you can recognise and revoke it later.
- Scopes — you can only grant scopes you hold yourself.
- Live or test — a test (sandbox) key never touches real data.
- An optional expiry.
The full key is shown once, at creation. Copy it then and store it safely — if you lose it, revoke it and create a new one.
Authentication
Authenticate every request with a bearer token in the Authorization header. Paste the key exactly as shown, including its prefix:
Authorization: Bearer wa_live_<id>|<token>
A sandbox key carries the wa_test_ prefix instead:
Authorization: Bearer wa_test_<id>|<token>
One user belongs to exactly one company, so a token fixes the account it acts on — there is no tenant parameter to pass.
Scopes
Scopes are the platform's own permission names, so a key can never do more than a dashboard user with the same grants. Available scopes:
| Scope | Description |
|---|---|
contacts.view |
Read contacts |
contacts.manage |
Manage contacts |
inbox.access |
Messages (read & send) |
tags.manage |
Manage tags |
quick-replies.manage |
Manage quick replies |
commerce.view |
Read orders & products |
commerce.manage |
Manage orders & products |
campaigns.view |
Read campaigns |
campaigns.manage |
Control campaigns (pause, resume, cancel) |
calendar.view |
Read bookings |
calendar.manage |
Manage bookings |
A key is minted with exactly the scopes you tick — never a wildcard, and never more than you hold yourself.
Response envelope
A successful response wraps the payload in a data key, with an optional meta block (used by paginated lists):
{
"data": { ... },
"meta": { "next_cursor": null, "has_more": false }
}
A failure returns a single, uniform error object:
{
"error": {
"type": "invalid_request_error",
"code": "not_found",
"message": "The requested resource was not found.",
"param": null,
"request_id": "req_01J..."
}
}
Error codes
The code string is a stable, machine-readable contract (locale-independent, never renamed) so you can branch on it; the human message is translated separately. The type is the coarse error family.
| Code | Type | HTTP |
|---|---|---|
bad_request |
invalid_request_error |
400 |
validation_failed |
invalid_request_error |
422 |
unauthenticated |
authentication_error |
401 |
forbidden |
authorization_error |
403 |
not_found |
invalid_request_error |
404 |
method_not_allowed |
invalid_request_error |
405 |
not_acceptable |
invalid_request_error |
406 |
conflict |
idempotency_error |
409 |
idempotency_conflict |
idempotency_error |
409 |
payload_too_large |
invalid_request_error |
413 |
rate_limited |
rate_limit_error |
429 |
server_error |
api_error |
500 |
Every error echoes request_id (the same value as the X-Request-Id response header) — quote it when reporting an issue.
Rate limits
The API allows 120 requests per minute per token (unauthenticated requests are throttled by client IP). Every response carries the standard headers:
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,Retry-After- Exceeding the budget returns 429 with the rate_limited code; back off until Retry-After.
Idempotency
Send an Idempotency-Key header on any POST, PUT, PATCH, or DELETE so a retried request is applied at most once. Use a fresh UUID per logical operation:
Idempotency-Key: 6f9e0b2c-1a3d-4e5f-8a7b-9c0d1e2f3a4b
- A replay of the same key + body returns the stored response with Idempotency-Replayed: true.
- Keys are remembered for 24 hours.
- Reusing a key with a different body, or while the first request is still in flight, returns 409 idempotency_conflict.
Pagination
Lists use opaque, cursor-based (keyset) pagination. Pass limit (default 25, max 100) and an opaque cursor:
GET https://console.widers.net/api/v1/contacts?limit=25&cursor=<opaque>
The response meta carries the next page cursor and whether more rows exist. When has_more is false, next_cursor is null:
"meta": { "next_cursor": "eyJ...", "has_more": true }
A malformed cursor returns 422 — never a silent full scan. Treat the cursor as an opaque blob and pass it back verbatim.
Sandbox / test mode
Test mode is carried by the key (a test key), so you never flip it mid-request. A sandbox request never touches real customers, orders, or money — it synthesises responses:
- Every response reports its mode:
X-Widers-Mode: live/X-Widers-Mode: test - A test-mode key short-circuits before any real side effect, so it fires no webhooks — every webhook you receive is from live activity.
- Side-effecting services stub their external calls in test mode.
Events
Your webhook endpoint receives an event whenever something happens in the account. Each event has a stable, dot-notated name — a public contract: it is versioned and never renamed. An event belongs to a scope, and a key can subscribe to (and receive) only the events its scopes allow. Subscribe under Settings → Webhooks.
| Event | What it is | Group | Scope |
|---|---|---|---|
flow.completed |
Automation completed | Automation | — |
flow.handoff_requested |
Handoff requested | Automation | — |
conversation.csat_received |
Satisfaction rating received | Automation | — |
invoice.paid |
Invoice paid | Billing | — |
booking.created |
Booking created | Booking webhooks | calendar.view |
booking.updated |
Booking updated | Booking webhooks | calendar.view |
booking.cancelled |
Booking cancelled | Booking webhooks | calendar.view |
booking.confirmed |
Booking confirmed | Booking webhooks | calendar.view |
campaign.completed |
Campaign completed | Campaigns | campaigns.view |
campaign.failed |
Campaign failed | Campaigns | campaigns.view |
contact.created |
Contact created | Contact webhooks | contacts.view |
contact.updated |
Contact updated | Contact webhooks | contacts.view |
contact.blocked |
Contact blocked | Contact webhooks | contacts.view |
contact.unblocked |
Contact unblocked | Contact webhooks | contacts.view |
contact.tagged |
Contact tagged | Contact webhooks | contacts.view |
contact.untagged |
Contact untagged | Contact webhooks | contacts.view |
contact.deleted |
Contact deleted | Contact webhooks | contacts.view |
message.received |
Message received | Message webhooks | inbox.access |
message.sent |
Message sent | Message webhooks | inbox.access |
conversation.created |
Conversation created | Message webhooks | inbox.access |
conversation.assigned |
Conversation assigned | Message webhooks | inbox.access |
conversation.transferred |
Conversation transferred | Message webhooks | inbox.access |
conversation.closed |
Conversation closed | Message webhooks | inbox.access |
conversation.reopened |
Conversation reopened | Message webhooks | inbox.access |
conversation.muted |
Conversation muted | Message webhooks | inbox.access |
conversation.unmuted |
Conversation unmuted | Message webhooks | inbox.access |
conversation.archived |
Conversation archived | Message webhooks | inbox.access |
conversation.unarchived |
Conversation unarchived | Message webhooks | inbox.access |
conversation.tagged |
Conversation tagged | Message webhooks | inbox.access |
conversation.untagged |
Conversation untagged | Message webhooks | inbox.access |
message.delivered |
Message delivered | Message webhooks | inbox.access |
message.read |
Message read | Message webhooks | inbox.access |
message.failed |
Message failed | Message webhooks | inbox.access |
conversation.unassigned |
Conversation unassigned | Message webhooks | inbox.access |
order.created |
Order placed | Orders | commerce.view |
order.paid |
Order paid | Orders | commerce.view |
order.shipped |
Order shipped | Orders | commerce.view |
order.ready_for_pickup |
Order ready for pickup | Orders | commerce.view |
order.delivered |
Order delivered | Orders | commerce.view |
order.cancelled |
Order cancelled | Orders | commerce.view |
order.refunded |
Order refunded | Orders | commerce.view |
cart.abandoned |
Cart abandoned | Orders | commerce.view |
product.created |
Product created | Orders | commerce.view |
product.updated |
Product updated | Orders | commerce.view |
GET /events lists the events your token may subscribe to; GET /events/{event}/samples returns the most recent real delivery envelopes for one event, so you can build and test against its exact shape.
Webhook signatures
Every webhook we POST to your endpoint is signed with a timestamped HMAC in the X-Widers-Signature header, so you can verify both authenticity and freshness:
X-Widers-Signature: t=1706342400,v1=<hmac_sha256>
- Verify against the raw, unparsed request body — compute hmac_sha256(secret, "<t>.<body>").
- Reject any request whose timestamp is more than 300 seconds from now (replay defence).
- Compare with a constant-time function (hash_equals) to avoid timing attacks.
- During a secret rotation the header carries more than one v1 (the new and the previous secret). Accept the request if ANY v1 matches — the verifier below does.
A copy-paste verifier (PHP):
<?php
function widers_verify(string $secret, string $rawBody, string $header): bool
{
// Parse "t=<unix>,v1=<hex>[,v1=<hex>…]" — there may be MORE THAN ONE v1 while a secret is rotating.
$t = 0;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') {
$t = (int) $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($t <= 0 || abs(time() - $t) > 300) {
return false; // missing/stale timestamp — outside the 300s replay window
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $signature) { // accept ANY matching v1, so rotation never drops a delivery
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
The same check in Node.js:
const crypto = require('crypto');
function widersVerify(secret, rawBody, header) {
let t = 0;
const sigs = [];
for (const part of header.split(',')) {
const [k, v = ''] = part.trim().split('=');
if (k === 't') t = parseInt(v, 10);
else if (k === 'v1') sigs.push(v);
}
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return sigs.some((s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
And in Python:
import hashlib, hmac, time
def widers_verify(secret: str, raw_body: str, header: str) -> bool:
t, sigs = 0, []
for part in header.split(","):
k, _, v = part.strip().partition("=")
if k == "t":
t = int(v or 0)
elif k == "v1":
sigs.append(v)
if t <= 0 or abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
Webhook delivery
Every event is delivered as a POST with this JSON envelope. Branch on type, and keep api_version in view so you stay compatible when a future payload gains fields:
POST https://your-endpoint.example/webhooks
Content-Type: application/json
X-Widers-Signature: t=1706342400,v1=<hmac_sha256>
X-Widers-Delivery: evt_01J...
{
"id": "evt_01J...",
"type": "order.paid",
"api_version": "v1",
"created_at": "2026-10-30T12:00:00+00:00",
"data": { ... }
}
- Delivery is at-least-once — the same event can arrive more than once. The envelope id (also echoed in the X-Widers-Delivery header) is stable per delivery: dedupe on it. Order is not guaranteed; sort by created_at when it matters.
- A delivery that does not return 2xx within 10 seconds — or returns 4xx/5xx — is retried with exponential backoff: after 1 minute, 10 minutes, 1 hour, 6 hours, then 24 hours (six attempts in all).
- After 15 consecutive failed deliveries the subscription auto-disables — to protect your endpoint and ours — and is flagged in the dashboard. Fix your endpoint, then re-enable it there; re-enabling clears the failure streak.
- Return 2xx as soon as you have stored the event, then process it asynchronously. A slow handler times out and triggers needless retries.
- Rotate an endpoint’s secret from the dashboard whenever you need to: the previous secret keeps working for a 24-hour grace window (both are sent, each as its own v1), so you can update your stored secret anytime before it closes.
Create a subscription in the dashboard (Settings → Webhooks) or over the API — the signing secret is shown once, at creation:
curl -X POST https://console.widers.net/api/v1/webhooks \
-H "Authorization: Bearer wa_live_<id>|<token>" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-endpoint.example/webhooks","topics":["order.paid","message.failed"]}'
Trigger a flow
A flow can expose its own signed webhook URL — add an “Inbound webhook” trigger in the flow builder to mint one. POST your JSON to it and the flow runs for the contact resolved from the payload, with that payload available to the flow as data.
POST https://console.widers.net/flow/webhook/<token>
Authenticate with the trigger’s secret, either way — pick whichever your caller finds easiest:
- As a bearer token (recommended): Authorization: Bearer <secret> — or the X-Widers-Token header. The secret is the credential, like an API key; nothing to compute.
- Or with the same signature as outbound webhooks: X-Widers-Signature: t=<unix>,v1=<hmac_sha256(secret, "t.body")> — so one signer/verifier works in both directions.
curl -X POST https://console.widers.net/flow/webhook/<token> \
-H "Authorization: Bearer <trigger_secret>" \
-H "Content-Type: application/json" \
-d '{"phone":"+9665XXXXXXXX","name":"..."}'
Responses: 202 Accepted on success; 422 when no contact or conversation could be resolved from the payload; 401 for a bad secret or signature; 404 for an unknown or disabled trigger; 413, 415 or 429 for an oversized body, a non-JSON body, or too many requests.
Your first request
The quickest check that a key works: GET /me returns the account and the connection the key acts on. It needs no scope, so it is the ideal first call:
curl https://console.widers.net/api/v1/me \
-H "Authorization: Bearer wa_live_<id>|<token>" \
-H "Accept: application/json"
Then list your contacts with a single authenticated request:
curl https://console.widers.net/api/v1/contacts \
-H "Authorization: Bearer wa_live_<id>|<token>" \
-H "Accept: application/json"
Calendar Bookings (legacy auth)
- It authenticates with the legacy Sanctum abilities, not the scopes above:
bookings.read/bookings.write - It returns the default Laravel envelope, not the data/error shape:
{"data":[...]} - It has no cursor pagination, no sandbox mode, and no idempotency support — its list is unpaginated and hard-capped.
Changelog
Backward-compatible additions only — new fields may appear, so parse leniently. A breaking change would ship under a new api_version with v1 kept running, and any deprecation is announced here in advance.
2026-10-30— Send from a specific channel: the message-send endpoint accepts a connection id, and GET /connections lists your connected numbers with their health.2026-10-30— Event catalog: GET /events lists the events a token may subscribe to, and GET /events/{event}/samples returns recent real envelopes.2026-10-30— Webhook envelopes now carry an api_version field, and the inbound flow-trigger accepts the same t=,v1= signature as outbound webhooks.2026-10-30— Webhook subscriptions auto-disable after repeated delivery failures and can be re-enabled from the dashboard.2026-10-30— CORS: rate-limit and idempotency response headers are now exposed to browser clients.
Ready to explore every endpoint? See the API Reference