WidersApps WidersApps

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.

ResourceWhat you can doScope
ContactsList · view · create · update · deletecontacts.view · contacts.manage
ProductsList · view · create + update core fields (name, price, status, stock — for catalog/inventory sync)commerce.view · commerce.manage
OrdersList · view · create · advance statuscommerce.view · commerce.manage
MessagesList · view · sendinbox.access
ConversationsList · view · assign · transfer · close · reopen · read · mute · archive · follow · note · taginbox.access
BookingsFull CRUD + availabilitycalendar.view · calendar.manage
TagsList tags; create, rename and delete your own (system tags are read-only)inbox.access · tags.manage
MediaUpload a file and get a URL to attach when sending a messageinbox.access
Quick repliesList and create saved quick repliesquick-replies.manage
CampaignsList broadcasts with their stats; pause, resume or cancel a running onecampaigns.view · campaigns.manage
Creating an order always raises it as Pending — no status is accepted on create, so revenue is never realised without payment. Advancing an order to a paid status stamps its paid_at without collecting any money (for manual, bank-transfer or cash-on-delivery flows) and is gated on commerce.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>
The wa_live_ / wa_test_ prefix is cosmetic — it is stripped before the token is parsed. Live vs. test mode is enforced by the key's ability, never by the prefix, so renaming a prefix cannot change what a key can do.

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:

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

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

EventWhat it isGroupScope
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)

Calendar Bookings predates this API kit and keeps its own contract. Its differences are listed below.
  • 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