Skip to content

Webhooks ​

Otpera sends an HTTPS POST to your endpoint when something happens in your workspace. Add endpoints in Dashboard → Webhooks; each one gets its own signing secret.

Events ​

EventWhen
order.createdAn order was created (API, Shopify, …)
order.status_changedThe confirmation status changed (customer answer, team member, expiry)
webhook.testYou pressed Send test in the dashboard

Payload ​

json
{
  "id": "evt_01J…",
  "type": "order.status_changed",
  "created_at": "2026-11-05T10:04:12.000Z",
  "workspace_id": "…",
  "data": {
    "order": { "id": "…", "external_id": "1042", "source": "shopify" },
    "from": "pending",
    "to": "confirmed",
    "actor_type": "customer",
    "reason": "whatsapp_choice"
  }
}

reason is e.g. whatsapp_choice, whatsapp_reply, confirmation_page or whatsapp_choice:postpone_requested (customer asked to postpone → to: "needs_review").

Headers ​

HeaderValue
Otpera-Event-IdStable event id; use it to deduplicate
Otpera-Event-Typee.g. order.status_changed
Otpera-TimestampUnix seconds
Otpera-Delivery-Attempt1, 2, 3 …
Otpera-Signaturet=<unix>,v1=<hex>

Verify the signature ​

v1 is HMAC-SHA256(secret, "<t>.<raw body>") as hex. Always verify against the raw request body, and reject old timestamps (e.g. older than 5 minutes).

js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
app.post('/webhooks/otpera', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Otpera-Signature') ?? '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = crypto
    .createHmac('sha256', process.env.OTPERA_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body}`)
    .digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const ok =
    fresh &&
    parts.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  // Deduplicate on event.id, then handle event.type …
  res.sendStatus(200);
});
php
public function otpera(Request $request)
{
    parse_str(str_replace(',', '&', $request->header('Otpera-Signature', '')), $sig);
    $raw = $request->getContent();
    $expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $raw, config('services.otpera.webhook_secret'));

    if (abs(time() - (int) ($sig['t'] ?? 0)) > 300 || ! hash_equals($expected, $sig['v1'] ?? '')) {
        abort(401);
    }

    $event = json_decode($raw, true);
    // Deduplicate on $event['id'], then handle $event['type'] …
    return response()->noContent();
}

Laravel

Exclude your webhook route from CSRF protection, or Otpera's requests get 419.

Delivery & retries ​

  • Any 2xx within 10 seconds counts as delivered.
  • Otherwise Otpera retries after 1 min, 5 min, 30 min, 2 h and 12 h (6 attempts in total).
  • Delivery is at-least-once and order isn't guaranteed: deduplicate on Otpera-Event-Id and don't rely on arrival order.
  • Endpoints must be public https:// URLs (port 443 or 8443). Redirects aren't followed.
  • Dashboard → Webhooks shows every delivery with the first 300 characters of your response, and lets you redeliver.

otpera.com · WhatsApp order confirmation & OTP