REST API · VERSION 1

Connect your software.
Keep the conversation moving.

Send messages, track delivery and receive signed events through one independent WhatsApp gateway.

PHP firstPersistent queueSigned webhooks

Your first message

  1. Create an API key in the dashboard with messages:send. Save it once.
  2. Copy your session ID from WhatsApp sessions. Set WTP_SESSION_ID, WTP_URL and WTP_API_KEY in your server environment.
  3. Choose your language below, replace the phone placeholder and send.
  4. Save queueId, messageId and referenceId. Queued is not delivery confirmation.

Local development: http://localhost:3000. Use the HTTPS origin of your configured gateway.

Authentication & isolation

Use X-API-Key for all v1 endpoints. Scopes: messages:send, messages:read, session:read. Existing keys retain all three. Each key can read only messages created with that same key, including its earlier queue jobs. Dashboard sends and incoming messages have no application-key owner. Incoming events use owner-scoped webhooks with optional session subscriptions. Include X-WTP-Session-ID on API requests. Only an explicitly configured owned default permits omission; the migrated legacy owner retains its original default. Pending, rejected and suspended users cannot use API keys.

API keys are secrets. Use server environment variables, never HTML, browser JavaScript, mobile binaries, public repositories or URLs. Fetch examples run server-side; public browsers call your own authenticated backend. This page never requests or stores keys.

5-minute PHP integration

Raw PHP needs PHP 8.0+, curl and json. No Composer required. Put image.jpg/result.pdf alongside the example. SDK media also needs fileinfo. Set environment values, then run php examples/php/send-text.php.

The optional SDK is in sdk/php. Copy the directory and require autoload.php, or use a local Composer path repository. It is not published to Packagist. Select PHP SDK where available. See docs/laravel.md for Laravel controllers and webhook routes.

GET message accepts queueId or WhatsApp messageId. Timestamps are ISO 8601, nullable until observed. Delivery/read receipts are never fabricated.

Loading examples…

References & safe retries

referenceId identifies your own record, for example order-1001. It is unique within one API key. An exact repeat returns the existing message; a changed payload returns 409. Idempotency-Key is also scoped to the key. Keep both unchanged when retrying an uncertain request; use new identifiers only for new messages. Replacing a key does not transfer ownership.

List filters: page (1–1,000,000), limit (1–100), status, direction, type, phone, inclusive ISO dateFrom/dateTo. Incoming returns empty because no application owns inbound messages. Webhooks are preferred; polling is for reconciliation.

Receive signed events

Dashboard → Webhooks: create an endpoint, select events, copy the secret once and set WTP_WEBHOOK_SECRET at your receiver. Verify HMAC-SHA256(secret, timestamp + "." + rawBody) before JSON decoding. Compare in constant time; reject timestamps more than 300 seconds in either direction.

Headers: X-WTP-Event, X-WTP-Event-ID, X-WTP-Timestamp (Unix seconds), X-WTP-Signature (sha256=hex). Envelope: id, event, createdAt, data. Outgoing events include referenceId (nullable). data.messageId is the stored numeric ID as a string; whatsappMessageId is separate.

Events: session.connected/disconnected/logged_out/qr_required; message.received/queued/processing/sent/delivered/read/failed/cancelled; queue.paused/resumed. Delivered/read require actual provider receipts.

At-least-once delivery: deduplicate event IDs persistently in the same transaction as business updates. HTTP 2xx succeeds. Default attempts: immediate, 30s, 2m, 10m, 30m, 2h. Network failures, 408/425/429/5xx retry; other 4xx and redirects fail. HTTPS public destinations only; localhost requires development mode plus ALLOW_LOCAL_WEBHOOKS=true. Rotated secrets apply to future attempts; an in-flight request may use the old secret.

Laravel integration (12.x example, no WTP SDK required)

Use Laravel's HTTP client on the server. Protect every controller that sends a message with your own authentication/authorization. Never put WTP keys into browser props or JavaScript. The webhook route is authenticated by HMAC, not browser cookies.

Environment and config

WTP_URL=https://your-server.example
WTP_API_KEY=YOUR_API_KEY
WTP_WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET

Add to config/services.php, then refresh your normal Laravel config cache:

'wtp' => [
    'url' => env('WTP_URL'),
    'key' => env('WTP_API_KEY'),
    'webhook_secret' => env('WTP_WEBHOOK_SECRET'),
],

Client service: app/Services/Wtp.php

<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;

class Wtp {
    private function request(string $reference) {
        return Http::baseUrl(rtrim(config('services.wtp.url'), '/'))
            ->withHeaders(['X-WTP-Session-ID' => config('services.wtp.session_id'), 'X-API-Key' => config('services.wtp.key'), 'Idempotency-Key' => $reference])
            ->withoutRedirecting()->connectTimeout(5)->timeout(30);
    }
    public function text(string $to, string $message, string $reference): array {
        return $this->request($reference)->post('/api/v1/messages/text', [
            'to' => $to, 'message' => $message, 'referenceId' => $reference,
        ])->throw()->json();
    }
    public function image(string $to, string $path, string $reference): array {
        return $this->upload('image', $to, $path, 'image/jpeg', $reference);
    }
    public function pdf(string $to, string $path, string $reference): array {
        return $this->upload('document', $to, $path, 'application/pdf', $reference);
    }
    private function upload(string $kind, string $to, string $path, string $mime, string $reference): array {
        if (!is_file($path) || !is_readable($path)) throw new \RuntimeException('File not found');
        $stream = fopen($path, 'rb');
        try {
            return $this->request($reference)->attach('file', $stream, basename($path), ['Content-Type' => $mime])
                ->post('/api/v1/messages/'.$kind, ['to' => $to, 'caption' => 'Your document', 'referenceId' => $reference])
                ->throw()->json();
        } finally { fclose($stream); }
    }
}

Example authenticated controller action (the external application supplies its own record ID):

public function send(\Illuminate\Http\Request $request, \App\Services\Wtp $wtp) {
    $data = $request->validate(['to'=>'required|string', 'message'=>'required|string|max:4096', 'reference'=>'required|string|max:128']);
    // Authorize access to the record represented by reference before sending.
    return response()->json($wtp->text($data['to'], $data['message'], $data['reference']));
}

Catch Illuminate\Http\Client\RequestException at your application boundary. Read `$e->response->status()` and `$e->response->header('Retry-After')`. Fix 400/401/403/409/413 rather than blindly retrying. On timeout, 429 or 5xx use a bounded retry policy with the SAME reference/key. The example does not retry automatically.

Durable webhook inbox

Create your own application migration:

Schema::create('wtp_webhook_inbox', function (\Illuminate\Database\Schema\Blueprint $table) {
    $table->string('id', 64)->primary();
    $table->string('payload_hash', 64);
    $table->string('event');
    $table->text('payload');
});

Place this route in routes/api.php, outside the web/CSRF middleware group, and ensure that file is registered in bootstrap/app.php. Its usual URL is `/api/wtp/webhook`. Do not disable CSRF for other routes.

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;

Route::post('/wtp/webhook', function (Request $request) {
    $raw = $request->getContent();
    abort_if(strlen($raw) > 1048576, 413);
    $secret = (string) config('services.wtp.webhook_secret');
    $timestamp = (string) $request->header('X-WTP-Timestamp');
    $signature = (string) $request->header('X-WTP-Signature');
    abort_if($secret === '', 503);
    abort_unless(preg_match('/^\d{10}$/D', $timestamp) && abs(time() - (int) $timestamp) <= 300, 401);
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
    abort_unless(hash_equals($expected, $signature), 401);
    try { $event = json_decode($raw, true, 64, JSON_THROW_ON_ERROR); }
    catch (\JsonException $e) { abort(401); }
    abort_unless(is_array($event) && is_string($event['id'] ?? null) && preg_match('/^evt_[a-f0-9]{32}$/D', $event['id']) && ($event['id'] ?? '') === $request->header('X-WTP-Event-ID') && ($event['event'] ?? '') === $request->header('X-WTP-Event'), 401);
    DB::transaction(function () use ($event, $raw) {
        $hash = hash('sha256', $raw);
        $inserted = DB::table('wtp_webhook_inbox')->insertOrIgnore([
            'id'=>$event['id'], 'payload_hash'=>$hash, 'event'=>$event['event'], 'payload'=>$raw,
        ]);
        $row = DB::table('wtp_webhook_inbox')->where('id', $event['id'])->first();
        abort_unless($row && hash_equals($row->payload_hash, $hash), 409);
        if ($inserted) {
            switch ($event['event']) {
                case 'message.received': case 'message.sent': case 'message.delivered':
                case 'message.read': case 'message.failed':
                    // Business updates in THIS transaction, or consume durable inbox later.
                    break;
            }
        }
    });
    return response()->json(['success'=>true]);
});

Set a 1 MB body limit at your reverse proxy too. The receiver acknowledges only after the inbox commits; events survive restart and duplicate delivery does not insert twice.

Official references: [Laravel HTTP client](https://laravel.com/docs/12.x/http-client), [Laravel CSRF exclusions](https://laravel.com/docs/12.x/csrf). Adapt routing registration to your installed Laravel version. Laravel is not installed in this WTP repository; these examples are not an executed Laravel application.

Errors & rate limits

All errors: {success:false,error:{code,message}}. 401 UNAUTHORIZED: check key; 403 INSUFFICIENT_SCOPE: check permissions; 400 INVALID_PHONE, EMPTY_MESSAGE, INVALID_REFERENCE, INVALID_IDEMPOTENCY_KEY, INVALID_REQUEST: fix input; 409 DUPLICATE_REFERENCE, IDEMPOTENCY_CONFLICT: do not change payload for an existing identifier; 404 MESSAGE_NOT_FOUND also covers other keys’ messages.

Uploads: UNSUPPORTED_FILE_TYPE, INVALID_IMAGE, EMPTY_FILE, INVALID_FILE_NAME, FILE_REQUIRED, REMOTE_URL_DISABLED; 413 FILE_TOO_LARGE. Images: JPG/PNG/WebP. Documents: PDF only. Default limit 10 MB, configurable. Text/caption maximum 4096 characters. No remote URLs or base64.

Default 100 API requests/minute/user (shared by that user’s keys), configurable by SUPER_ADMIN, with X-RateLimit-Limit and X-RateLimit-Remaining. 429 RATE_LIMITED or UPLOAD_BUSY includes Retry-After seconds. Sending limits are separate: defaults 10/minute, 500/rolling day, 4–7 second pacing. 503 SERVER_STOPPING: retry later using the same identifiers. REQUEST_FAILED covers other errors. Never retry indefinitely. Disconnected WhatsApp may still accept queued messages.

v1 is stable; future breaking changes use v2. POST /api/send remains a legacy alias.