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 TypeDescription
deploy.succeededA BPMN process definition was deployed successfully. Data: count and deployments.
deploy.failedA BPMN deployment was rejected (invalid model or engine error). Data: resource and error.
credits.topped_upYour credit balance was increased by a completed purchase. Data: credits and pack_id.
credits.lowYour 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.receivedA 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.acknowledgedThe recipient acknowledged a message your account sent.
message.completedThe 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.failedAn email your account sent failed at SMTP or bounced permanently (status failed or dead_letter).
testSent 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.

RouteWhat it does
POST /api/v1/webhooksBody {"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}/testSends 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

  1. Priostack signs the exact raw request body with your webhook's secret, the one you passed to POST /api/v1/webhooks or the one it generated and returned there.
  2. 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.
  3. 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:

AttemptWhen
1st attemptAs soon as the event occurs
2nd attempt0.5 seconds after the first one failed
3rd attempt1 second after the second one failed
After 3 failed attemptsThe 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"
Timeout: Your webhook handler must respond within 10 seconds. Long-running work should be processed asynchronously - return 200 immediately and process in a background queue.