Loading
Loading
Pictage / Docs / Webhooks
Every outbound webhook from Pictage is signed with HMAC-SHA256 using a per-endpoint secret that you saw once when you created the endpoint at Settings → Integrations → Webhooks. This page shows how to verify the signature so your subscriber can trust the payload.
X-Pictage-Signature: t=<unix_seconds>,v1=<hex> where the signing string is ${t}.${raw_request_body}. During a signing-secret rotation (see below) the header also carries a v0=<hex> field, computed the same way with your prior secret.X-Pictage-Event-Id is a UUID v4. The same id is sent on every retry of an event so you can dedupe.X-Pictage-Event-Type is one of gallery.created, gallery.published, gallery.delivered, shoot.created, shoot.completed, photo.uploaded, ai_job.done, ai.cull.completed, payment.received, order.shipped, order.created, order.sent_to_print, order.delivered, order.cancelled, order.refunded, order.needs_intervention, client.created, booking.requested, booking.accepted, booking.declined, proposal.sent, proposal.viewed, proposal.accepted, proposal.declined, proposal.expired, contract.signed, invoice.paid, review.approved. Full schema + example payload per event: docs/api/webhooks.md.X-Pictage-Signature header. Extract t and v1.t is older than 5 minutes (clock skew window).expected = HMAC_SHA256(secret, "${t}.${raw_body}").hex().v1. If equal, the payload is authentic. Process; respond 2xx.v0 instead (when the header has one). Either match is a valid, authentic delivery.Use POST /api/v1/webhook-endpoints/:id/rotate-secret (or the "Rotate secret" button on Settings → Integrations → Webhooks) when a secret leaks or you just want to refresh it, without any downtime. For 24 hours after rotating, every delivery carries both a v1 signature (the new secret) and a v0 signature (the old one), so you can update your stored secret on your own schedule instead of racing a hard cutover. After 24 hours only v1 is sent.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.PICTAGE_WEBHOOK_SECRET;
// Use express.raw so the body stays intact for HMAC verification.
app.post(
'/pictage-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.header('X-Pictage-Signature') || '';
const [tField, v1Field] = sig.split(',').map((p) => p.trim());
const t = Number(tField.replace('t=', ''));
const v1 = v1Field.replace('v1=', '');
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
return res.status(400).send('expired');
}
const expected = createHmac('sha256', SECRET)
.update(`${t}.${req.body.toString('utf8')}`)
.digest('hex');
if (
expected.length !== v1.length ||
!timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
) {
return res.status(400).send('bad signature');
}
const payload = JSON.parse(req.body.toString('utf8'));
// ... dispatch on payload.type ...
res.status(200).send('ok');
},
);import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['PICTAGE_WEBHOOK_SECRET'].encode('utf-8')
@app.post('/pictage-webhook')
def pictage_webhook():
sig = request.headers.get('X-Pictage-Signature', '')
parts = dict(p.strip().split('=', 1) for p in sig.split(',') if '=' in p)
t = int(parts.get('t', '0'))
v1 = parts.get('v1', '')
if abs(time.time() - t) > 300:
abort(400, 'expired')
raw = request.get_data()
expected = hmac.new(SECRET, f'{t}.{raw.decode()}'.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(400, 'bad signature')
payload = request.get_json(force=True)
# ... dispatch on payload['type'] ...
return '', 200# config/routes.rb
post '/pictage-webhook', to: 'webhooks#pictage'
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def pictage
secret = ENV['PICTAGE_WEBHOOK_SECRET']
sig = request.headers['X-Pictage-Signature'].to_s
parts = sig.split(',').map { |p| p.strip.split('=', 2) }.to_h
t = parts['t'].to_i
v1 = parts['v1']
return head :bad_request if (Time.now.to_i - t).abs > 300
raw = request.body.read
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{t}.#{raw}")
return head :bad_request unless ActiveSupport::SecurityUtils.secure_compare(expected, v1)
payload = JSON.parse(raw)
# ... dispatch on payload['type'] ...
head :ok
end
endPictage retries failed deliveries 5 times with exponential backoff: 30s, 2m, 10m, 30m, 2h. After the 6th failure (or any terminal 4xx response), the delivery is moved to dead_letter and stops retrying. Your endpoint owner sees the failure on Settings → Integrations → Webhooks with the last HTTP status and error body.
Pictage sends every event with the same X-Pictage-Event-Id across retries. Dedupe on that id if your handler is not idempotent.
`data` is the one field that differs per event type - see docs/api/webhooks.md for every event's schema and example payload.
{
"id": "00000000-0000-4000-8000-000000000001",
"type": "gallery.created",
"created_at": "2026-05-28T12:34:56.000Z",
"workspace_id": "00000000-0000-4000-8000-000000000003",
"api_version": "2026-05",
"data": {
"gallery_id": "00000000-0000-4000-8000-000000000004",
"shoot_id": "00000000-0000-4000-8000-000000000010",
"title": "Demo Wedding",
"slug": "demo-wedding",
"photo_count": 420
}
}Questions? Email support@pictage.ai.