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
| Event | When |
|---|---|
order.created | An order was created (API, Shopify, …) |
order.status_changed | The confirmation status changed (customer answer, team member, expiry) |
webhook.test | You 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
| Header | Value |
|---|---|
Otpera-Event-Id | Stable event id; use it to deduplicate |
Otpera-Event-Type | e.g. order.status_changed |
Otpera-Timestamp | Unix seconds |
Otpera-Delivery-Attempt | 1, 2, 3 … |
Otpera-Signature | t=<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
2xxwithin 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-Idand 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.