API リファレンス
Receiving Events

Webhook delivery & signature verification

How UnifyPort delivers events to your endpoint over HTTP POST, the X-Device-* headers on every delivery, and how to verify the HMAC-SHA256 signature so you can trust the payload. Also covers the response we expect, retries, and idempotency.

Delivery headers

  • X-Device-Event-Id

    Stable id of the event. Stays the same across retries of the same event — pair it with the delivery id for idempotency.

  • X-Device-Delivery-Id

    Id of this individual delivery attempt. Falls back to the event id when not separately set.

  • X-Device-Timestamp

    RFC 3339 UTC time the delivery was signed (e.g. 2026-06-08T12:34:56Z). It is part of the signed string and also lets you reject stale deliveries.

  • X-Device-Signature

    Hex-encoded HMAC-SHA256 of "<X-Device-Timestamp>" + "." + "<raw request body>". Present only when the endpoint has a signing_secret; omitted when signing is disabled.

  • Content-Type

    Always application/json.

Verify the signature

Node.js (Express)

import crypto from 'crypto';
import express from 'express';

const app = express();
const SECRET = process.env.WEBHOOK_SIGNING_SECRET;

// express.raw keeps the body as the exact bytes we signed — never re-serialize.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let timestamp = req.get('X-Device-Timestamp');
  if (!timestamp) {
    timestamp = '';
  }
  let signature = req.get('X-Device-Signature');
  if (!signature) {
    signature = '';
  }

  const hmac = crypto.createHmac('sha256', SECRET);
  hmac.update(timestamp + '.');
  hmac.update(req.body); // raw Buffer
  const expected = hmac.digest('hex');

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!valid) return res.status(401).end();

  const event = JSON.parse(req.body.toString('utf8'));
  // handle event.type ...
  res.status(200).end(); // any 2xx acknowledges the delivery
});

Python (Flask)

import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SIGNING_SECRET"].encode()

@app.post("/webhook")
def webhook():
    timestamp = request.headers.get("X-Device-Timestamp", "")
    signature = request.headers.get("X-Device-Signature", "")
    body = request.get_data()  # raw bytes, exactly as delivered

    expected = hmac.new(
        SECRET, timestamp.encode() + b"." + body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401)

    event = request.get_json()
    # handle event["type"] ...
    return "", 200

注記

  • Acknowledge with any 2xx status. The response body is read and discarded; a non-2xx response counts as a failed delivery.
  • Connection errors and 408 / 429 / 5xx responses are retried up to retry_policy.max_attempts times (default 3). Other 4xx responses are not retried — the event is dead-lettered.
  • Delivery is at-least-once. Retries reuse the same X-Device-Event-Id, so deduplicate on it rather than assuming exactly-once.
  • Verify against the raw request bytes, before any JSON parse or re-serialize. Re-encoding the body changes the bytes and breaks the signature.
  • Reject deliveries whose X-Device-Timestamp is too far from your clock to limit replay — the timestamp is covered by the signature.
  • Signing is per-endpoint: set signing_secret on Create or Update webhook endpoint to enable it; leave it empty to disable and drop the X-Device-Signature header.
  • Delivery order is not guaranteed — events from the same conversation can arrive out of order (a read receipt may land before the message it refers to). Order by occurred_at, with the event id as a tiebreaker, before applying state.
  • UnifyPort does not persist message history and has no message read API — webhook events are the only record of traffic. Store what you need when the event arrives; there is no way to backfill missed payloads later.