Skip to main content
Version: Peki

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​

  1. Create an automation and choose When → Webhook.
  2. If the sending system signs its requests, choose its Signature provider and the Signing secret (see Signatures).
  3. Set up the rest: usually an Action Function that reads the payload, and optionally a Task and Notify.
  4. Save. "The webhook URL appears after you save."
  5. Copy the Webhook URL and paste it into the sending service. "Treat it like a password."
  6. 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.
  7. 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 POST is 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​

PartRule
MethodPOST
Body sizeUp 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-urlencodedTurned into an object. A repeated field becomes a list.
Anything elseKept as text.
Empty bodyThe 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.

StatusBodyWhen
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​

LimitValue
Body size256 KB
Requests per URL60 per minute
Requests from one network to all URLs600 per minute
Runs of one automation at the same time4 (more wait in line, they are never dropped)
Runs of one automation waiting in line100 (more get 429)
Duplicate window24 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:

  1. Stripe (with Stripe signatures on): the event id in the body.
  2. The X-Shopify-Webhook-Id, X-GitHub-Delivery or Idempotency-Key header.
  3. 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."

ProviderHeaderWhat is signed
StripeStripe-Signature: t=<timestamp>,v1=<hex>HMAC-SHA256 of <timestamp>.<raw body>. The timestamp must be within 5 minutes.
ShopifyX-Shopify-Hmac-Sha256: <base64>HMAC-SHA256 of the raw body, base64.
GitHubX-Hub-Signature-256: sha256=<hex>HMAC-SHA256 of the raw body, hex.
HMAC-SHA256A header you name, such as x-signatureHMAC-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.

Unsigned webhooks

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:

StatusMeaning
AcceptedQueued; Open run jumps to its run.
DuplicateSeen in the last 24 hours; didn't run.
Bad signatureThe signature check failed.
Too many requestsOver a rate limit.
Too largeOver 256 KB.
Automation offThe 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.

tip
Use --data-raw or --data-binary

curl -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:

EventLabelStarts when
record.createdRecord createdA record is created in one of the agent's tables.
record.updatedRecord updatedA record's values really change (a new conversation link alone is not a change).
conversation.handed_offConversation handed offThe agent hands a customer conversation to your team.
conversation.resolvedConversation resolvedSomeone resolves a customer conversation in the Inbox.
approval.decidedApproval decidedA 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/false compare 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:

record.created / record.updated
{
"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"]
}
conversation.handed_off
{ "conversationId": "6720…", "channel": "webchat", "customerId": "671f…", "customerName": "Jane Cooper", "reason": "Wants a refund" }
conversation.resolved
{ "conversationId": "6720…", "channel": "telegram", "customerId": "671f…", "customerName": "Jane Cooper", "resolvedBy": "Alex" }
approval.decided
{
"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.trigger holds what started the run. ctx.records reads and saves the agent's tables. ctx.store is 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​

  1. Under Settings → Secrets, save Shopify's webhook signing secret as a signing secret.
  2. Create an automation: When → Webhook, Signature → Shopify, pick the secret.
  3. Action: a Function that reads the order from ctx.trigger.payload, saves the customer with ctx.records.save('leads', …) and returns { "message": "New order from …" }.
  4. Notify: your Telegram chat. No task.
  5. Save, copy the URL, and paste it in Shopify → Settings → Notifications → Webhooks (event: Order creation, format: JSON).
  6. Run once with a sample order (a dry run), check the lead and the message it would create, then turn the automation on.