Webhook API
This is the technical reference for PrimeCal's incoming webhook receiver — the endpoint an
external system calls to trigger a webhook.incoming automation rule. For a product walkthrough,
see Webhooks in the User Guide.
Every call to this endpoint is verified with an HMAC-SHA256 signature. There is no way to disable
this per rule, and an unsigned request is rejected outright. Plan for signing from the start; a
bare curl -d '{...}' POST will fail.
Endpoint
POST https://api.primecal.eu/api/automation/webhook/{token}
GET https://api.primecal.eu/api/automation/webhook/{token}
{token} is unique per rule (webhookToken), found in the rule's Webhook tab in the app and
regenerable from there.
- POST — send your payload as a JSON body (
Content-Type: application/json). - GET — for callers that can't send a POST body (IoT buttons, browser-triggered links). Query string parameters become the payload instead.
Both variants require the same signature.
Getting your signing secret
Each rule has a webhookSecret, generated automatically when the rule is created. The web app
does not currently display it — the only way to retrieve or rotate it is this endpoint:
POST /api/automation/rules/{id}/webhook/rotate-secret
Authenticated as the rule's owner (normal JWT auth, not the webhook token). Response:
{ "webhookSecret": "<new 128-char hex secret>", "graceUntil": "2026-09-26T12:00:00.000Z" }
Calling this rotates the secret — the previous secret keeps working until graceUntil
(1 hour by default), then stops. There's no read-only "show current secret" call; if you've lost
the secret, rotating is the only recovery.
Signing a request
- Timestamp — current Unix time in seconds, as a string.
- Signed payload —
"{timestamp}.{rawRequestBody}"(the exact bytes you send, not a re-serialized version — for GET requests, use an empty string in place of the body). - Signature —
HMAC-SHA256(webhookSecret, signedPayload), hex-encoded.
Send both as headers:
| Header | Value |
|---|---|
X-PrimeCal-Timestamp | The Unix timestamp (seconds) used above |
X-PrimeCal-Signature | The hex signature, optionally prefixed sha256= |
(X-Webhook-Signature/X-Webhook-Timestamp and X-Hub-Signature-256 are also accepted, for
compatibility with common webhook-sender conventions.)
Example (Node.js)
const crypto = require('crypto');
const secret = 'the webhookSecret from rotate-secret';
const body = JSON.stringify({ event: 'order.completed', data: { order_id: '12345' } });
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
await fetch('https://api.primecal.eu/api/automation/webhook/YOUR_TOKEN', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-PrimeCal-Timestamp': timestamp,
'X-PrimeCal-Signature': signature,
},
body,
});
Example (curl, precomputed)
SECRET="the webhookSecret from rotate-secret"
BODY='{"event":"order.completed","data":{"order_id":"12345"}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST "https://api.primecal.eu/api/automation/webhook/YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "X-PrimeCal-Timestamp: $TS" \
-H "X-PrimeCal-Signature: $SIG" \
-d "$BODY"
Timestamp tolerance and replay protection
- The timestamp must be within 5 minutes of the server's clock (
WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS, default 300). Outside that window the request is rejected — this also means you can't replay an old captured request after the window closes. - Each
(token, signature, timestamp)combination can only be used once. A byte-identical retry of the same signed request is rejected as a replay.
Rotating without downtime
rotate-secret gives your new requests a new secret while the old one still verifies
until graceUntil (default 1 hour). Update your external system's stored secret within that
window; there's no way to extend it after the fact — rotate again if you need more time.
Rate limits
Webhook calls are rate-limited per rule: 60 requests per 60-second window by default
(AUTOMATION_WEBHOOK_MAX_PER_WINDOW / AUTOMATION_WEBHOOK_WINDOW_MS), independent of the general
API rate limits in API Rate Limits. Exceeding it returns an error naming
the retry delay.
Optional: restricting by source IP
A rule can be configured with an IP allowlist (triggerConfig) so only requests from specific
source IPs are accepted, on top of signature verification. This is set via the
Automation API, not currently exposed in the rule builder UI.
Responses
| Status | Meaning |
|---|---|
| 200 | { "success": true, "ruleId": <id>, "message": "Webhook processed successfully" } |
| 400 | Malformed signature format, or the rule isn't a webhook.incoming rule, or the rule is disabled |
| 401 | Missing/invalid signature, timestamp outside tolerance, or a replayed request |
| 403 | The rule has no webhookSecret at all (should not happen for a normally-created rule) |
| 404 | Token doesn't match any rule |
| 429 | Rate limit exceeded |
A 200 means the request was accepted and evaluated — it does not mean the rule's conditions matched. Check the rule's audit log (Automation API) to see whether the action actually ran.
Related
- Webhooks (User Guide) — payload shape, conditions, practical examples
- Automation API — rule CRUD, audit logs, token regeneration
- API Rate Limits