Triggers, webhooks and events
A trigger is what starts an automation. Besides a schedule, an automation can start the moment something happens: another system calls its webhook URL, or something happens inside Peki (a Peki event), like a new lead being saved. Use triggers for "when an order is created in Shopify, save the customer and message me" or "when a lead's status becomes Won, send it to our CRM".
You choose the trigger under When in the automation's Setup: Schedule, Webhook or Peki event. Everything after the trigger (check, continue when, action, task, notify, limits) works as described in Automations.
Webhooks
A webhook automation has its own URL. Another system (Shopify, Stripe, GitHub, a form tool, your own backend) sends an HTTP POST to it, and the run starts at once.
Set up a webhook automation
- Create an automation and choose When → Webhook.
- If the sending system signs its requests, choose its Signature provider and the Signing secret (see Signatures).
- Set up the rest: usually an Action Function that reads the payload, and optionally a Task and Notify.
- Save. "The webhook URL appears after you save."
- Copy the Webhook URL and paste it into the sending service. "Treat it like a password."
- Test with Run once and a sample payload (the Copilot can do this for you), or send a real request and use Run again as a dry run.
- Turn the automation on.
The URL
The URL looks like this:
POST https://peki.ai/hooks/whk_1a2B3c4D5e6F7g8H9i0J1k2L3m4N5o6P
whk_followed by 32 random characters, made by Peki. You can't choose it. Copy the exact URL from the panel.- The URL is the credential: anyone who has it can call it. Only editors and above see it. Viewers see "Only editors see the webhook URL. Ask an editor for it."
- No session, cookie or API key is needed. Don't add one.
- Only
POSTis accepted.
Renew URL gives you a new URL; the old one stops working at once. Revoke URL turns the URL off: requests get 404 until you click Create a new URL. Editing the automation never brings a revoked URL back. Both actions are recorded in the audit log.
Request
| Part | Rule |
|---|---|
| Method | POST |
| Body size | Up to 256 KB. Larger bodies get 413. |
application/json (also …+json) | Parsed as JSON. JSON nested deeper than 64 levels, or invalid JSON, is kept as text. |
application/x-www-form-urlencoded | Turned into an object. A repeated field becomes a list. |
| Anything else | Kept as text. |
| Empty body | The payload is null. |
Your check and action Functions read the request as ctx.trigger:
{
"kind": "webhook",
"payload": { "id": "evt_1Q2w", "type": "payment_intent.succeeded", "data": { "object": { "amount": 4900 } } },
"headers": { "content-type": "application/json", "user-agent": "Stripe/1.0" },
"id": "6720f0c1a2b3c4d5e6f70812"
}
headers holds only these request headers, cut at 200 characters each. Signatures and credentials are never passed on.
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
The task never sees the raw payload. It gets a short summary (up to 2 KB) with personal data and key-like values masked, inside a block marked as untrusted data: the model is told never to follow instructions inside it.
Response
Peki answers right away. The run happens in the background.
| Status | Body | When |
|---|---|---|
202 | {"accepted": true, "id": "<delivery id>"} | Accepted; the run is queued. |
200 | {"duplicate": true} | The same delivery arrived in the last 24 hours. It doesn't run again. |
401 | {"error": "Invalid signature"} | The signature check failed, or the signing secret is missing. |
404 | {"error": "Not found"} | Unknown or revoked URL, the automation is paused or off, or its trigger is no longer a webhook. |
413 | {"error": "Payload too large"} | Body over 256 KB. |
429 | {"error": "Too many requests"} | Over the rate limit, or too many runs of this automation are already waiting. |
503 | {"error": "Try again shortly"} | The request couldn't be queued. Retry later. |
Most sending services retry on 5xx and 429. A retry of a delivery that was accepted gets 200 with duplicate: true.
Limits
| Limit | Value |
|---|---|
| Body size | 256 KB |
| Requests per URL | 60 per minute |
| Requests from one network to all URLs | 600 per minute |
| Runs of one automation at the same time | 4 (more wait in line, they are never dropped) |
| Runs of one automation waiting in line | 100 (more get 429) |
| Duplicate window | 24 hours |
The automation's own Runs per day and Model cost per day limits apply too.
Duplicates
A delivery counts as a duplicate when its key was seen in the last 24 hours. The key is, in this order:
- Stripe (with Stripe signatures on): the event
idin the body. - The
X-Shopify-Webhook-Id,X-GitHub-DeliveryorIdempotency-Keyheader. - Otherwise a hash of the body.
With Shopify, GitHub or HMAC-SHA256 signatures, the body hash counts too, so the same signed body sent again under a new delivery id is still a duplicate. Duplicates are checked only after the signature passed.
Send an Idempotency-Key header from your own systems to control this.
Signatures
Pick a provider under Signature to check that requests are real. Requests without a valid signature are rejected (401) and don't run. "The signing secret is never sent anywhere."
| Provider | Header | What is signed |
|---|---|---|
| Stripe | Stripe-Signature: t=<timestamp>,v1=<hex> | HMAC-SHA256 of <timestamp>.<raw body>. The timestamp must be within 5 minutes. |
| Shopify | X-Shopify-Hmac-Sha256: <base64> | HMAC-SHA256 of the raw body, base64. |
| GitHub | X-Hub-Signature-256: sha256=<hex> | HMAC-SHA256 of the raw body, hex. |
| HMAC-SHA256 | A header you name, such as x-signature | HMAC-SHA256 of the raw body, as hex or base64 (a sha256= prefix is allowed). |
The Signing secret is a key of the agent, picked with the secret picker (at least 8 characters). Copy it from the sending system, such as Shopify's webhook signing secret. The model never sees it. For HMAC-SHA256, also fill in Signature header.
Signatures are computed over the exact bytes received. If you build requests yourself, sign the same bytes you send.
With Signature → None, "Anyone with the URL can start this automation." An unsigned webhook can't make the automation write (its action, writing tools, Add to Knowledge) unless you turn on Accept unsigned requests. Turn it on only if the sending service can't sign its requests.
Failed signature checks are recorded in Requests and in the audit log.
Requests
Under the URL, Requests shows the last 20 requests ("No requests yet. They show here for 30 days."). Each row has the time, a status, the reason and the size:
| Status | Meaning |
|---|---|
| Accepted | Queued; Open run jumps to its run. |
| Duplicate | Seen in the last 24 hours; didn't run. |
| Bad signature | The signature check failed. |
| Too many requests | Over a rate limit. |
| Too large | Over 256 KB. |
| Automation off | The automation was paused or off. |
Open a row to see a preview of the body: at most 2 KB, with personal data and key-like values masked. Peki never stores the full body. Run again as a dry run runs the automation on that stored preview, without sending or writing anything.
Rejected requests are recorded at most 20 per minute per URL, so a flood doesn't fill the list.
Examples with curl
Set your URL and secret first:
HOOK_URL='https://peki.ai/hooks/whk_REPLACE_WITH_YOUR_URL_ID_000000'
SECRET='your-signing-secret'
Unsigned JSON (only for automations that don't write, or with Accept unsigned requests):
curl -i -X POST "$HOOK_URL" \
-H 'Content-Type: application/json' \
--data-raw '{"name":"Jane Cooper","email":"[email protected]","interest":"Teeth whitening"}'
HTTP/1.1 202 Accepted
Content-Type: application/json
{"accepted":true,"id":"6720f0c1a2b3c4d5e6f70812"}
A form post becomes an object ({"name": "Jane Cooper", "service": "Cleaning"}):
curl -i -X POST "$HOOK_URL" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'name=Jane Cooper' --data-urlencode 'service=Cleaning'
HMAC-SHA256 with the header x-signature and an idempotency key:
BODY='{"event":"form.submitted","name":"Jane Cooper","phone":"+14155550123"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -i -X POST "$HOOK_URL" \
-H 'Content-Type: application/json' \
-H "x-signature: $SIG" \
-H 'Idempotency-Key: form-2026-10-26-0001' \
--data-raw "$BODY"
GitHub style:
BODY='{"action":"opened","issue":{"number":42,"title":"Login fails"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -i -X POST "$HOOK_URL" \
-H 'Content-Type: application/json' \
-H 'X-GitHub-Event: issues' \
-H "X-GitHub-Delivery: $(uuidgen)" \
-H "X-Hub-Signature-256: sha256=$SIG" \
--data-raw "$BODY"
Shopify style (base64 signature):
BODY='{"id":820982911946154500,"email":"[email protected]","total_price":"49.00"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)
curl -i -X POST "$HOOK_URL" \
-H 'Content-Type: application/json' \
-H 'X-Shopify-Topic: orders/create' \
-H "X-Shopify-Webhook-Id: $(uuidgen)" \
-H "X-Shopify-Hmac-Sha256: $SIG" \
--data-raw "$BODY"
Stripe style (timestamped):
BODY='{"id":"evt_1Q2w3e4r","type":"checkout.session.completed","data":{"object":{"amount_total":4900}}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -i -X POST "$HOOK_URL" \
-H 'Content-Type: application/json' \
-H "Stripe-Signature: t=$TS,v1=$SIG" \
--data-raw "$BODY"
Sending the same Stripe request twice gives 200 and {"duplicate":true} the second time, because the event id is the same.
--data-raw or --data-binarycurl -d can change the body (it strips newlines from files), and then the signature no longer matches. Sign and send the exact same bytes.
Peki events
An event automation starts when something happens inside Peki. Choose When → Peki event and pick the Event:
| Event | Label | Starts when |
|---|---|---|
record.created | Record created | A record is created in one of the agent's tables. |
record.updated | Record updated | A record's values really change (a new conversation link alone is not a change). |
conversation.handed_off | Conversation handed off | The agent hands a customer conversation to your team. |
conversation.resolved | Conversation resolved | Someone resolves a customer conversation in the Inbox. |
approval.decided | Approval decided | A teammate approves or rejects an approval request. |
Events never come from tests: test records, test conversations, draft approval requests, and the hidden conversations of the Copilot and automations don't emit. An approval that expires is not a decision and emits nothing.
Events are written together with the change itself, so none are lost if the service restarts. Each event starts each matching automation once.
Filter record events
For Record created and Record updated you can narrow the event:
- Table: Any table or one table of the agent.
- Field (after a table): Any field, or one field. "With a field, it runs only when that field changes (to the value, when you give one)."
- Value (after a field): the value the field changes to. Choice fields offer their options. Text compares without regard to case; numbers and
true/falsecompare by value.
Example: Record updated · Leads · Status · Won runs only when a lead's status becomes Won.
Filters are judged on the values at the moment of the change, not on the record as it is later.
Event payloads
ctx.trigger.payload for each event:
{
"table": "leads",
"tableId": "t_x7k2ab",
"recordId": "6720f0c1a2b3c4d5e6f70812",
"title": "Jane Cooper",
"data": { "name": "Jane Cooper", "phone": "+14155550123", "status": "Won", "budget": { "amount": 2500, "currency": "USD" } },
"changedFields": ["status"]
}
{ "conversationId": "6720…", "channel": "webchat", "customerId": "671f…", "customerName": "Jane Cooper", "reason": "Wants a refund" }
{ "conversationId": "6720…", "channel": "telegram", "customerId": "671f…", "customerName": "Jane Cooper", "resolvedBy": "Alex" }
{
"approvalId": "6721…", "decision": "approved", "status": "approved", "kind": "record", "name": "leads",
"preview": "Save to Leads: name Jane Cooper, status Won", "conversationId": "6720…", "recordId": "6720…"
}
data holds the record's values by field key. customerId, customerName, conversationId, recordId and automationId appear only when they exist. status can be failed when the approved action couldn't run.
Loop protection
An automation can cause events itself, for example its action saves a record. Peki keeps a chain of automations behind each event:
- An event never starts the automation whose run caused it.
- After 3 automations in a row from one event, the chain stops.
A run that was stopped this way is recorded as Skipped — loop. If an automation already has 100 runs waiting, a new event is recorded as Skipped — previous run still going and dropped.
Actions
An action is one of the agent's Functions run with write access, without a model, after the check and condition. It is how an automation talks to other systems: post a lead to a CRM, create a ticket, update a sheet.
// Action Function: send a won lead to the CRM
export default async (input, ctx) => {
const lead = ctx.trigger.payload.data;
const res = await ctx.fetch('https://api.example-crm.com/v1/deals', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: lead.name, phone: lead.phone, value: lead.budget?.amount }),
secret: { name: 'CRM_API_KEY' },
});
return { ok: res.ok, message: `New won deal sent to the CRM: ${lead.name}` };
};
ctx.triggerholds what started the run.ctx.recordsreads and saves the agent's tables.ctx.storeis the automation's memory.- Its writes are approved when you save the automation, by someone with the editor role or above.
- With Needs approval on the Function, the action becomes an approval request holding the exact input and the trigger (up to 64 KB). It runs when a teammate approves. See Approvals.
- No task, no model. When there is no task, the action's
message(if it returns one) is sent to the Telegram chats. Such a run costs almost nothing. - In a dry run, the action's writing requests are answered by the system and never sent, and record saves are only reported.
If the action fails, the run fails with the Function's error.
Outgoing calls need no separate "outgoing webhook" setting: the action (or a task with an HTTP tool) makes them.
Test a trigger
On the Run once tab of a webhook or event automation, a run with a sample payload behaves like a real trigger: the sample becomes ctx.trigger.payload. The Copilot does this with a realistic sample (a Shopify order, a lead) before telling you where to paste the URL. The sample may be up to 256 KB and is removed right after the run.
From Requests, Run again as a dry run replays a stored, masked preview. It's always a dry run.
Runs started by a trigger show what started them on the Runs tab: Webhook request, Peki event: Record created, Run once with a sample, or "after 1 automation" for chained events.
Example: Shopify order to a lead and a message
- Under Settings → Secrets, save Shopify's webhook signing secret as a signing secret.
- Create an automation: When → Webhook, Signature → Shopify, pick the secret.
- Action: a Function that reads the order from
ctx.trigger.payload, saves the customer withctx.records.save('leads', …)and returns{ "message": "New order from …" }. - Notify: your Telegram chat. No task.
- Save, copy the URL, and paste it in Shopify → Settings → Notifications → Webhooks (event: Order creation, format: JSON).
- Run once with a sample order (a dry run), check the lead and the message it would create, then turn the automation on.