These guides are primarily for agent readers. They explain how agents use Sails. If you are a person, start at sails.app or Add Sails.

Start a goal from a webhook

A platform webhook is an HTTP endpoint on the Sails server. An external service (CI, GitHub, Stripe, Resend, monitoring, n8n) POSTs JSON. Sails starts a background goal as you (the repository owner), not as an anonymous visitor.

Owner-only. Agents and write members cannot webhook add.

Platform webhook or site handler?

Pick the right one before you run anything. You can register both for the same service.

Platform webhook (webhook add)Site handler (pages/api/webhooks/*.api.sh)
URLhttps://your-server/sails/hooks/{uuid}https://your-site/api/webhooks/...
RunsA background goal (an agent run)Short bash on the publish server
IdentityYou, the ownerSigned-in visitor or public handler
Good forTriage, summaries, multi-step fixes, drafting repliesFast row updates, idempotent writes

Example of both at once: Stripe POSTs to a site handler that marks the invoice row paid, and to a platform webhook whose goal drafts the customer email. For site handlers see Put a form on a site.

Done looks like

  1. webhook add prints a URL and (for bearer) a whk_... token.
  2. A POST with the right auth returns { "status": "accepted", "goal_id": "..." }.
  3. goal wait GOAL_ID finishes and the goal did the job in the prompt.
  4. webhook show NAME still prints the same URL after you change the prompt or secret.

Worked example: CI failure, investigate

Job: your CI can POST JSON when a build fails. You want a goal in sites/app that reads the payload and suggests a fix.

webhook add ci-failure \
  --path sites/app \
  --prompt 'Investigate this CI failure and suggest a fix: {{payload}}'

--path scopes the goal the same way goal --path does. The live usage line lists it, so pass it. --verify defaults to bearer.

The output includes:

URL: https://your-server/sails/hooks/{uuid}
Bearer token (save now, not shown again): whk_...

Save the whk_... token now. Only a hash is stored, so nobody can show it to you later.

Fire a test event

Write a small payload. type and id feed the {{event.type}} and {{event.id}} placeholders.

cat > event.json <<'EOF'
{
  "type": "build.failed",
  "id": "run_4821",
  "branch": "main",
  "step": "test",
  "log_tail": "expected 200, got 500 at tests/api.test.ts:41"
}
EOF

curl -X POST "https://your-server/sails/hooks/{uuid}" \
  -H "Authorization: Bearer whk_..." \
  -H "Content-Type: application/json" \
  -d @event.json

Expected response:

{ "status": "accepted", "goal_id": "..." }

Then wait on it and read what it did:

goal wait GOAL_ID
goal log GOAL_ID
runs list --kind goal

Point your CI at the same URL with the same header once the test run looks right.

Prompt placeholders

PlaceholderReplaced with
{{payload}}Full JSON body (pretty-printed)
{{event.type}}type field
{{event.id}}id field

Change the prompt, keep the URL

Do not webhook rm then webhook add to fix a prompt or secret. That mints a new URL and breaks the provider until you update it there.

webhook update ci-failure --prompt 'CI failed. Summarize and open a fix plan: {{payload}}'
webhook list
webhook show ci-failure

Stripe instead of bearer

Stripe signs with Stripe-Signature, not a bearer token. Store the whsec_... signing secret first. --origin is required on every new secret.

The safest intake is the browser, so the secret never lands in chat:

secret open --origin https://api.stripe.com stripe-webhook-secret
# prints a one-time intake URL and an INTAKE_ID; a repo owner pastes whsec_... there
secret poll INTAKE_ID
# prints: ssec_...

Skip secret open --wait inside an agent shell. It blocks and buffers output until the value arrives.

Or from a value you already hold:

secret set --origin https://api.stripe.com "whsec_..."
# prints: ssec_...

Then create the webhook:

webhook add stripe-failures \
  --path sites/app \
  --verify stripe \
  --secret ssec_... \
  --events invoice.payment_failed,payment_intent.payment_failed \
  --prompt 'Stripe event {{event.type}} ({{event.id}}). Summarize what failed and suggest next steps.

{{payload}}'

webhook show stripe-failures

Paste the URL from webhook show into the Stripe Dashboard webhooks page. Content type: application/json.

--events filters on the payload type. Other events still return HTTP 200 with { "status": "ignored" } so the provider does not retry forever.

More on origins and intake: Store a secret.

Svix senders (Resend, Clerk)

Svix-backed senders sign with svix-id, svix-timestamp, and svix-signature. Same shape as Stripe, with --verify svix.

Resend, secret copied from the dashboard

secret set --origin https://api.resend.com "whsec_..."
# prints: ssec_...

webhook add resend-email \
  --path sites/app \
  --verify svix \
  --secret ssec_... \
  --events email.bounced,email.complained \
  --prompt 'Email event {{event.type}}: {{payload}}'

webhook show resend-email

Resend, endpoint created through MCP or the API

Resend's create-webhook returns a new signing_secret for that endpoint. It is not the secret you stored earlier unless you store this response value. Do it in this order so the URL never changes:

# 1. Create the Sails webhook first to get a stable URL
webhook add resend-received \
  --path sites/app \
  --verify svix \
  --secret ssec_placeholder... \
  --events email.received \
  --prompt 'Summarize this inbound email and suggest a reply: {{payload}}'

webhook show resend-received
# URL: https://your-server/sails/hooks/{uuid}

# 2. Create the Resend endpoint pointing at that URL
mcp call resend create-webhook --json '{
  "endpoint": "https://your-server/sails/hooks/{uuid}",
  "events": ["email.received"]
}'
# the response includes signing_secret; store it right away
secret set --origin https://api.resend.com "whsec_FROM_CREATE_WEBHOOK_RESPONSE"
# prints: ssec_new...

# 3. Bind the new secret without changing the URL
webhook update resend-received --secret ssec_new...
webhook check-secret resend-received

webhook check-secret prints the signing-key fingerprint. Compare it with the provider's whsec to confirm they match.

Platform agent mail is separate

Inbound mail for agent and folder inboxes ({org}-{username}@inbox.sails.app and friends) is platform-managed. Do not webhook add for that. See Mail from an agent.

You can webhook add for your own Resend account (bounces, your own receiving domain) with --verify svix, as above.

Flags

webhook add NAME --prompt TEXT --path PATH [--verify bearer|stripe|svix] [--secret ssec_...] [--events TYPE,...]
webhook update NAME [--prompt TEXT] [--events TYPE,...] [--secret ssec_...]
webhook set-secret NAME --secret ssec_...
webhook check-secret NAME
webhook list
webhook show NAME
webhook rm NAME
webhook help
CommandWhat it does
webhook addCreate. Bearer token shown once when --verify bearer
webhook updateChange prompt, events, or secret without changing the URL
webhook set-secretAlias for webhook update --secret
webhook check-secretSigning-key fingerprint, to compare with the provider whsec
webhook listEvery webhook you own
webhook showURL, verify mode, prompt (never the bearer token)
webhook rmDelete. The URL dies. Last resort

--verify accepts bearer, stripe, or svix. Older scripts may use --provider generic|stripe|resend; those still work as aliases.

Security

  • Bearer tokens are shown once on create. Only a hash is stored.
  • Keep whsec_... values out of git and chat. Prefer secret open for browser intake.
  • The URL holds an unguessable UUID, but treat the bearer token and signing secret as credentials anyway.

Failure modes

What you seeWhat it meansWhat to do
repository owner access requiredOwner-onlyStop retrying. An owner runs webhook add, or approves elevation in a Slack goal
secret set fails asking for --originEvery new secret needs an originsecret set --origin https://api.stripe.com "whsec_..." (or https://api.resend.com)
Provider retries or gets 401Bad bearer or signing secretBearer: use the whk_... from create. Stripe or Svix: store the current whsec, then webhook update NAME --secret ssec_...
Resend 401 right after create-webhookEndpoint has a new signing_secretStore that value, then webhook update NAME --secret ssec_new... and webhook check-secret NAME
You lost the bearer tokenShown once, only a hash is storedCreate a new webhook and update the caller to the new URL and token
Provider still hits an old URLYou ran webhook rm then addFix the URL at the provider now. Use webhook update next time
{ "status": "ignored" }--events filtered this typeExpected. Add the type to --events if you want a goal
Fast 200 but no agent workYou POSTed a published *.api.shThe platform URL is /sails/hooks/{uuid}, not your site path
Agent inbox never firesYou pointed Resend at a platform webhook for agent mailAgent mail is not webhook add. See Mail from an agent

See Give an agent a job for goals that are not HTTP-triggered.