Incoming webhooks
An automation with a Webhook trigger gets its own URL. When another system sends a request to that URL, the automation runs at once with the request's body as its input. Use it for "a new order came in", "a payment failed" or "someone filled in the contact form". This page is for whoever sets up the sending side; to build the automation itself, see Triggers.
The webhook URL
https://peki.ai/hooks/whk_9fK2mQ7xR4tV1bN8cZ5wL3pH6yJ0dS2a
- Each automation has its own URL. It appears in the automation under Webhook URL after you save. Only editors see it.
- The URL is the credential: anyone who has it can call it. "Treat it like a password."
- Renew URL gives a new URL; the old one stops working at once. Revoke URL turns it off; requests are refused until you create a new one.
- No API key or other header is needed.
The same URL also works under the API base (https://peki.ai/api/hooks/whk_…).
Send a request
POST /hooks/{hookId}
Only POST is accepted. The body becomes the automation's input:
Content-Type | The automation receives |
|---|---|
application/json or any …+json | The parsed JSON. If it isn't valid JSON, or nests deeper than 64 levels, the raw text. |
application/x-www-form-urlencoded | An object of the fields. A field sent more than once becomes a list. |
| Anything else | The body as text. |
| Empty body | Nothing (null). |
These request headers are passed on to the automation (each cut to 200 characters): content-type, user-agent, x-shopify-topic, x-shopify-shop-domain, x-shopify-webhook-id, x-github-event, x-github-delivery, x-github-hook-id, idempotency-key, x-request-id. Other headers aren't.
curl -X POST https://peki.ai/hooks/whk_9fK2mQ7xR4tV1bN8cZ5wL3pH6yJ0dS2a \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-10482-created" \
-d '{"event": "order.created", "order": {"id": 10482, "total": 89.90, "email": "[email protected]"}}'
await fetch('https://peki.ai/hooks/whk_9fK2mQ7xR4tV1bN8cZ5wL3pH6yJ0dS2a', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': `order-${order.id}-created` },
body: JSON.stringify({ event: 'order.created', order }),
});
Responses
| Status | Body | Meaning | Should the sender retry? |
|---|---|---|---|
202 | { "accepted": true, "id": "…" } | Accepted. The run starts right away. | No |
200 | { "duplicate": true } | Already received in the last 24 hours; it doesn't run again. | No |
401 | { "error": "Invalid signature" } | Signature checking is on and the signature is missing or wrong. | No, fix the signature |
404 | { "error": "Not found" } | Unknown, renewed or revoked URL, or the automation is paused or turned off. | No |
413 | { "error": "Payload too large" } | The body is larger than 256 KB. | No, send less |
429 | { "error": "Too many requests" } | Rate limit, or too many runs waiting for this automation. | Yes, later |
503 | { "error": "Try again shortly" } | It couldn't be queued. Nothing ran. | Yes |
The 202 comes back as soon as the request is accepted, before the automation runs. The sender never waits for the agent.
A paused automation answers 404, the same as an unknown URL, so the URL gives nothing away. The refused request still shows in the automation's Requests list with the reason.
Signatures
Without a signature check, anyone with the URL can start the automation: "Anyone with the URL can start this automation. Pick a provider to check signatures." Turn it on whenever the sending service signs its requests.
In the automation, under Signature, pick the provider and the Signing secret: the secret the sending service gave you, saved in Settings → Secrets for this agent. "A signing secret only checks the signatures of incoming requests; Peki never sends it anywhere."
| Provider | Header checked | How it's signed |
|---|---|---|
| Stripe | Stripe-Signature | t= timestamp and v1= HMAC-SHA256 of {t}.{raw body}. The timestamp must be within 5 minutes. |
| Shopify | X-Shopify-Hmac-Sha256 | Base64 HMAC-SHA256 of the raw body. |
| GitHub | X-Hub-Signature-256 | sha256= and the hex HMAC-SHA256 of the raw body. |
| HMAC-SHA256 | The Signature header you name, like x-signature | HMAC-SHA256 of the raw body with your secret, as hex or base64, optionally prefixed with sha256=. |
The signature is checked over the exact bytes received. Don't re-format the JSON between signing and sending.
Sign requests from your own system
Use HMAC-SHA256 with a header name of your choice:
import crypto from 'node:crypto';
const body = JSON.stringify({ event: 'form.submitted', name: 'Maria Lopez', phone: '+1 212 555 0100' });
const signature = crypto.createHmac('sha256', process.env.PEKI_SIGNING_SECRET).update(body).digest('hex');
await fetch(process.env.PEKI_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-signature': `sha256=${signature}` },
body,
});
BODY='{"event":"form.submitted","name":"Maria Lopez"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$PEKI_SIGNING_SECRET" -hex | sed 's/^.* //')
curl -X POST "$PEKI_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-H "x-signature: sha256=$SIG" \
-d "$BODY"
Unsigned requests that write
If an automation writes somewhere (its action, tools that change data, Knowledge) and has no signature check, it only runs after you turn on Accept unsigned requests: "Anyone who has the URL could make this automation write". Turn it on only if the sending service can't sign its requests.
Duplicates
Services re-send webhooks when they don't get a quick answer. Peki runs each event once and answers a repeat within 24 hours with 200 {"duplicate": true}. A request counts as the same event when it has the same:
- Stripe event
id(for Stripe), - or
X-Shopify-Webhook-Id,X-GitHub-DeliveryorIdempotency-Keyheader, - or exactly the same body.
For Shopify, GitHub and HMAC-SHA256, the same signed body is also a duplicate under a new delivery id, so a captured request can't be replayed.
From your own system, send an Idempotency-Key header with a stable id for the event, like order-10482-created. Then a retry never runs twice, even if the body changed slightly.
Limits
| Limit | Value |
|---|---|
| Body size | 256 KB |
| Requests per minute, per webhook URL | 60 |
| Requests per minute from one network, across all webhook URLs | 600 |
| Duplicate window | 24 hours |
| Requests shown in Requests | the last 20, kept for 30 days |
Over a limit, the request gets 429 and doesn't run. The automation's own daily run and spending limits still apply after a request is accepted.
The Requests list
Each automation with a webhook shows its recent Requests: time, status, Size and a short preview of the body with personal data masked. The full body isn't stored. From a request you can Run again as a dry run to test changes without writing anything. "Last 20 · runs on the stored preview: personal data is masked."
Rejected requests (paused, too large, rate limited, bad signature) are recorded too, up to 20 a minute per webhook. Signature failures are also written to the workspace's audit log.
Example: Shopify order created
- In Peki, create an automation with the Webhook trigger. Under Signature choose Shopify and save Shopify's webhook signing secret as the Signing secret.
- Save, and copy the Webhook URL.
- In Shopify, go to Settings → Notifications → Webhooks, create a webhook for Order creation, format JSON, and paste the URL.
- Send a test notification from Shopify and check Requests in the automation.