Webhooks
Priostack can send HTTP POST notifications to your endpoint when account events occur: a deployment, a credit top-up or low balance, or mailbox activity. Webhooks allow you to react to these events in real-time without polling the API.
Event Types
These are the only events Priostack sends. A webhook receives an event only if its name is in the webhook's events list, matched exactly (no wildcard); a webhook with an empty list receives nothing. No event is sent when a process instance starts or completes, when a job is activated or fails, or when an incident is raised: poll the API for those.
| Event Type | Description |
|---|---|
deploy.succeeded | A BPMN process definition was deployed successfully. Data: count and deployments. |
deploy.failed | A BPMN deployment was rejected (invalid model or engine error). Data: resource and error. |
credits.topped_up | Your credit balance was increased by a completed purchase. Data: credits and pack_id. |
credits.low | Your credit balance crossed a low-balance threshold (30, 20 or 10 credits remaining). Data: balance and threshold. Checked every 5 minutes, and sent once the low-balance email for that threshold has gone out. |
message.received | A message landed in your account's mailbox, on any channel (in-app or inbound email); the payload names the message without its text, which your agent reads with the mailbox API. |
message.acknowledged | The recipient acknowledged a message your account sent. |
message.completed | The recipient completed a request your account sent; the payload names the message without its text, and the result reference and note are read with the mailbox API. |
message.failed | An email your account sent failed at SMTP or bounced permanently (status failed or dead_letter). |
test | Sent only by POST /api/v1/webhooks/{id}/test, whatever the webhook's events list. |
Payload Format
Webhook payloads are JSON objects with the following structure:
{
"event": "deploy.succeeded",
"id": "evt-1727600000123456789",
"timestamp": "2026-09-29T14:30:00Z",
"data": {
"count": 1,
"deployments": [
{
"processDefinitionKey": "3f9c2a7e5b1d8c4e6a02",
"bpmnProcessId": "order-processing",
"id": "order-processing",
"version": 1,
"resourceName": "order.bpmn",
"tenantId": "ps_1a2b3...9f0e"
}
]
}
}
Credit Top-up Event
{
"event": "credits.topped_up",
"id": "evt-1727600600000000000",
"timestamp": "2026-09-29T15:00:00Z",
"data": {
"credits": 5556,
"pack_id": "small"
}
}
Managing Webhooks
The webhook routes accept the API key only in the X-API-Key header, and the key must belong to an account.
| Route | What it does |
|---|---|
POST /api/v1/webhooks | Body {"url", "events": [...], "secret"}; url is required. When secret is blank a 64-hex-character secret is generated. Answers 201 with the webhook, secret included: keep it. |
GET /api/v1/webhooks | {"webhooks": [...]}, secrets included. |
GET, PATCH, DELETE /api/v1/webhooks/{id} | Read one; change url, events or active; delete (answers {"ok": true}). |
POST /api/v1/webhooks/{id}/test | Sends one signed test event and answers {"status_code", "latency_ms", "success"}. |
GET /api/v1/webhooks/{id}/deliveries | {"deliveries": [...]}: every attempt with its status code, error and latency. |
Webhook ids look like wh- followed by a timestamp. The full field list is in the API reference.
Signature Verification
Every webhook request includes an X-Priostack-Signature header. Always verify it before processing the event.
How Signing Works
- Priostack signs the exact raw request body with your webhook's
secret, the one you passed toPOST /api/v1/webhooksor the one it generated and returned there. - The header value is
sha256=followed by the lowercase hex HMAC-SHA256 of the raw body: 71 characters in all. Compare against"sha256=" + hex, or strip the prefix and compare the decoded bytes; a comparison against the bare hex digest never matches. - Reject the request (401) when the header is missing, lacks the
sha256=prefix, is not 64 hex characters after it, or does not match. Compare in constant time, and only values of equal length.
Verify in Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strings"
)
// verifyWebhook reads the body and checks X-Priostack-Signature, which is
// "sha256=" followed by the hex HMAC-SHA256 of the raw body.
func verifyWebhook(r *http.Request, secret []byte) ([]byte, bool) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return nil, false
}
hexSig, ok := strings.CutPrefix(r.Header.Get("X-Priostack-Signature"), "sha256=")
if !ok {
return nil, false
}
got, err := hex.DecodeString(hexSig)
if err != nil {
return nil, false
}
mac := hmac.New(sha256.New, secret)
mac.Write(body)
// hmac.Equal runs in constant time and is false when the lengths differ.
return body, hmac.Equal(got, mac.Sum(nil))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
secret := []byte(os.Getenv("PRIOSTACK_WEBHOOK_SECRET"))
body, ok := verifyWebhook(r, secret)
if !ok {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var event struct {
Event string `json:"event"`
ID string `json:"id"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
log.Printf("event %s id=%s", event.Event, event.ID)
// Process event...
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/webhook", webhookHandler)
log.Fatal(http.ListenAndServe(":3000", nil))
}
Verify in Python
import hashlib
import hmac
import os
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["PRIOSTACK_WEBHOOK_SECRET"].encode()
@app.route("/webhook", methods=["POST"])
def webhook():
sig = request.headers.get("X-Priostack-Signature", "")
expected = "sha256=" + hmac.new(WEBHOOK_SECRET, request.get_data(), hashlib.sha256).hexdigest()
# compare_digest runs in constant time; bytes avoid a TypeError on non-ASCII input
if not sig.startswith("sha256=") or not hmac.compare_digest(sig.encode(), expected.encode()):
abort(401, "Invalid signature")
event = request.get_json()
print(f"Received event: {event['event']} id={event['id']}")
return "", 200
Verify in JavaScript
import crypto from "node:crypto";
import express from "express";
const app = express();
const WEBHOOK_SECRET = process.env.PRIOSTACK_WEBHOOK_SECRET;
// true only for "sha256=" + 64 hex characters matching the HMAC of the raw body;
// a missing, short or malformed header returns false instead of throwing.
function validSignature(header, rawBody) {
if (typeof header !== "string" || !header.startsWith("sha256=")) return false;
const hexSig = header.slice("sha256=".length);
if (!/^[0-9a-f]{64}$/i.test(hexSig)) return false;
const got = Buffer.from(hexSig, "hex");
const want = crypto.createHmac("sha256", WEBHOOK_SECRET).update(rawBody).digest();
return got.length === want.length && crypto.timingSafeEqual(got, want);
}
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!validSignature(req.headers["x-priostack-signature"], req.body)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
console.log("Event:", event.event, "ID:", event.id);
res.status(200).send("OK");
});
app.listen(3000);
Retry Behavior
If your endpoint returns a non-2xx status code or does not answer within 10 seconds, Priostack tries again, up to 3 attempts in total:
| Attempt | When |
|---|---|
| 1st attempt | As soon as the event occurs |
| 2nd attempt | 0.5 seconds after the first one failed |
| 3rd attempt | 1 second after the second one failed |
| After 3 failed attempts | The delivery is parked in the dead-letter queue: list it with GET /api/v1/dead-letters and send it again with POST /api/v1/dead-letters/{id}/reprocess. |
Every attempt is logged and visible through GET /api/v1/webhooks/{id}/deliveries. Deliveries to an address that resolves to loopback, private, link-local or multicast space are refused, so http://localhost never receives anything.
Idempotency
Because webhooks can be delivered more than once (retries, network issues), your handler must be idempotent. Use the event id field to deduplicate:
// Store processed event IDs (use Redis, DB, or in-memory set)
const processedEvents = new Set();
function handleEvent(event) {
if (processedEvents.has(event.id)) {
console.log("Duplicate event, skipping:", event.id);
return;
}
processedEvents.add(event.id);
// ... process event
}
Local Testing with ngrok
Priostack only delivers to public addresses, so use ngrok to expose your local server over HTTPS during development:
# 1. Start your local webhook server on port 3000
node webhook-server.js
# 2. In another terminal, start ngrok
ngrok http 3000
# 3. Copy the HTTPS URL from ngrok output, e.g.:
# https://abc123.ngrok.io
# 4. Register it, and keep the "secret" and "id" from the response
curl -X POST https://priostack.com/api/v1/webhooks \
-H "X-API-Key: your_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://abc123.ngrok.io/webhook", "events": ["deploy.succeeded"]}'
# 5. Fire a test event (or deploy a BPMN to get deploy.succeeded)
curl -X POST https://priostack.com/api/v1/webhooks/WEBHOOK_ID/test \
-H "X-API-Key: your_key"