Webhooks and schedules.
A trigger starts an automation without you: a webhook when GitHub, Stripe or one of your systems sends an event, or a schedule at the times you pick. Each start creates a run in your workspace with the automation’s settings, exactly like Run now.
An automation can have up to 5 triggers, of either kind. You manage them in its Triggers tab, at cloud.nan.builders/automations: + Webhook, + Schedule, and on each trigger Pause / Resume, Rotate (webhooks) and the bin icon to delete it. Adding, changing and deleting triggers needs a browser sign-in.
Add a webhook
Pick the sender
In the Triggers tab, press + Webhook and pick the Sender:
| Sender | Who sends it | Signing secret |
|---|---|---|
| GitHub | A repository’s or an organization’s webhooks. | NaN creates it. You paste it into GitHub. |
| Stripe | A webhook endpoint of your Stripe account. | Stripe creates it (whsec_...). You paste it here. |
| NaN (HMAC) | Your own scripts and systems. | NaN creates it. Your code signs with it. |
When the automation comes from a template, the sender and the event filter are already filled in.
For Stripe, Stripe gives you the signing secret, and you paste it in this form, so create the endpoint in Stripe first: follow From Stripe.
Choose which events start a run
Event filter (JSON, optional): which events start a run. See Event filters. For GitHub you can also limit Who can start it: see Only some GitHub users.
Copy the URL and the secret
Press Add webhook. The portal shows, only once:
- Webhook URL:
https://api.nan.builders/hooks/nan_whk_.... The URL itself is a credential: anyone who has it can send to it, so treat it like a password. - Signing secret (GitHub and NaN): what the sender signs each event with. For Stripe only the URL is shown: the secret is the one you pasted.
Copy them, then press I’ve saved them. Afterwards the portal only shows the start of the URL. If you lose them, rotate to get new ones.
Set it up on the sender
Follow the section for your sender: GitHub, Stripe or your own code.
From GitHub
In the repository, open Settings → Webhooks → Add webhook (or the same in your organization’s settings):
| GitHub field | Value |
|---|---|
| Payload URL | The webhook URL. |
| Content type | application/json. A form-encoded webhook is refused with 415. |
| Secret | The signing secret. |
| Which events | Let me select individual events, and tick the ones your automation handles: Pull requests for a review, Issues for triage, Workflow runs for CI failures. |
GitHub signs each delivery with X-Hub-Signature-256 and NaN checks it. When you save, GitHub sends a ping: it is always ignored and shows as Ignored by a filter in Deliveries. That tells you the URL and the secret work.
GitHub does not sign a timestamp, so NaN remembers each delivery id (X-GitHub-Delivery) and starts at most one run per delivery for 30 days. Redeliver in GitHub sends the same id: if it already started a run, it is answered as a duplicate.
From Stripe
Create the endpoint in Stripe
In the Stripe Dashboard, Developers → Webhooks → Add endpoint. Pick the events you want and, for now, any placeholder URL of yours: you change it in the last step. Stripe shows the endpoint’s Signing secret (whsec_...).
Paste the secret in NaN
Add the webhook in NaN with Stripe as the sender and paste that whsec_... in Stripe signing secret. It is stored encrypted and never shown again.
Point Stripe at the URL
Set the endpoint’s URL in Stripe to the webhook URL NaN gives you.
NaN checks Stripe-Signature, accepts any of the signatures Stripe sends (it sends several while you roll its secret) and refuses events more than 5 minutes off. Test and live mode both work: the endpoint you create decides which events you get. Each event id (evt_...) starts at most one run.
Send your own events
The NaN (HMAC) sender is for your own systems: a deploy script, a monitoring alert, another service. Send a POST with a JSON object as the body and these headers:
| Header | Required | Value |
|---|---|---|
X-Nan-Timestamp | Yes | The current time, in Unix seconds. Events more than 5 minutes off are refused. |
X-Nan-Signature | Yes | v1= followed by the hex HMAC-SHA256 of <timestamp>.<body> with the signing secret. You can send several, separated by commas, while you rotate the secret. |
X-Nan-Delivery | No | A unique id per event, up to 128 of A-Z a-z 0-9 . _ : -. The same id never starts two runs. Without it, the id is the hash of the timestamp and the body. The same timestamp and body are always one event, whatever this header says. |
X-Nan-Event | No | The event’s name, for your event filter. For example deploy.finished. |
X-Nan-Run-Id | No | If a run caused this event, its id (it is in NAN_RUN_ID inside the run). It feeds the loop protection. |
Sign the body exactly as you send it, byte for byte. In bash:
URL="https://api.nan.builders/hooks/nan_whk_..."
SECRET="..." # the signing secret
BODY='{"service":"api","version":"1.4.2"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -s "$URL" \
-H "Content-Type: application/json" \
-H "X-Nan-Timestamp: $TS" \
-H "X-Nan-Signature: v1=$SIG" \
-H "X-Nan-Event: deploy.finished" \
-d "$BODY"
In Python:
import hashlib, hmac, json, time, urllib.request
url = "https://api.nan.builders/hooks/nan_whk_..."
secret = b"..."
body = json.dumps({"service": "api", "version": "1.4.2"}).encode()
ts = str(int(time.time()))
sig = hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
req = urllib.request.Request(url, data=body, method="POST", headers={
"Content-Type": "application/json",
"X-Nan-Timestamp": ts,
"X-Nan-Signature": f"v1={sig}",
"X-Nan-Event": "deploy.finished",
})
print(urllib.request.urlopen(req).read().decode())
In Node.js:
import { createHmac } from 'node:crypto';
const url = 'https://api.nan.builders/hooks/nan_whk_...';
const secret = '...';
const body = JSON.stringify({ service: 'api', version: '1.4.2' });
const ts = Math.floor(Date.now() / 1000).toString();
const sig = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Nan-Timestamp': ts,
'X-Nan-Signature': `v1=${sig}`,
'X-Nan-Event': 'deploy.finished',
},
body,
});
console.log(res.status, await res.text());
Keep the signing secret on your side
Keep the URL and the signing secret in your systems’ secret store, never in a repository or in client-side code. With both, anyone can start your automation.
Event filters
The Event filter is a JSON object. Each list you fill must match, and an empty list lets everything through. If you leave the field empty, the webhook uses the filter of the automation’s template (when the sender is the template’s); to let every event through, write {}:
| Field | Matches | Example |
|---|---|---|
events | The event name: X-GitHub-Event for GitHub, the event type for Stripe, X-Nan-Event for NaN. An entry ending in .* matches a prefix. | ["pull_request"], ["invoice.*"] |
actions | GitHub’s action field. | ["opened", "synchronize"] |
exclude_drafts | true ignores draft pull requests. | true |
conclusions | The conclusion of a GitHub workflow_run. | ["failure"] |
The Pull request review template, for example, uses:
{
"events": ["pull_request"],
"actions": ["opened", "synchronize", "reopened", "ready_for_review"],
"exclude_drafts": true
}
Up to 32 entries per list, each of A-Z a-z 0-9 _ . : -. An event that does not match is answered 200 and listed as Ignored by a filter.
Only some GitHub users
By default, any event with a valid signature starts a run. On a public repository that means anyone who can open a pull request or an issue. To limit it, tick Only some GitHub users under Who can start it:
- GitHub logins: only events sent by these users. Empty means any.
- Relationship to the repository: only authors with that relationship (owner, member, collaborator, contributor, first time contributor, first timer, none). None ticked means any.
- Ignore bots: skip events sent by bot accounts.
You can change it later with Change on the trigger.
What the agent receives
The event reaches the agent as data, after your instructions and clearly marked as coming from outside. It is never taken as instructions.
- GitHub pull request, issue and workflow run events arrive trimmed to the fields that matter, with long strings cut with a marker:
- Pull requests: the repository, the number, the title, the body (up to 8 KiB), the author and their relationship to the repository, the branches and commit, the link and how many files changed.
- Issues: the repository, the number, the title, the body (up to 8 KiB), the author, the labels and the link.
- Workflow runs: the repository, the workflow, the run, the branch and commit, the link and the related pull requests.
- Any other event (other GitHub events, Stripe, NaN) arrives as its JSON body, if it is at most 48 KiB and nested at most 8 levels. Otherwise it is listed as Too large and no run starts.
Webhook bodies are capped at 1 MiB.
What the sender gets back
| Status | When | Body |
|---|---|---|
202 | A run was created. | {"status":"run_created","run_id":"..."} |
200 | Final, without a run: filtered out, a repeated delivery, a loop, a run already active, not accepted (see Deliveries), or too large. | {"status":"ignored"}, "duplicate", "loop_suppressed", "skipped_active", "not_accepted" or "too_large" |
401 | The signature is missing or wrong, or the timestamp is too far off. | Empty |
404 | Unknown URL, or a paused trigger or a disabled automation. | Empty |
413 | The body is over 1 MiB. | Empty |
400 | The body could not be read. | Empty |
415 | The body is not a JSON object (a GitHub webhook not sent as application/json, for example), or a Stripe event without a valid event id. | Empty |
429 | Too many deliveries for now, the automation reached its runs per hour, or your queue is full. Retry later (Retry-After). | Empty |
500, 503 | Something failed on our side, or temporarily unavailable. Retry later. | Empty |
A 200 is final: the sender should not retry it. A 429, 500 or 503 is not recorded, so a retry or a redelivery is processed as new.
Deliveries
The Deliveries tab lists the last 50 webhooks the automation received in the last 30 days (up to 100 with --limit in the CLI or limit in the API), newest first, with when it arrived, its delivery id, what happened and the run it started:
| Outcome | Meaning |
|---|---|
| Run started | A run was created. |
| Ignored by a filter | The event or actor filter did not match. |
| Stopped: caused by one of its own runs | A loop was stopped. |
| Skipped: a run for the same subject is active | See When a run is already active. |
| Not accepted: your membership does not include runs | The run could not be created: your plan has no inference, or the workspace is stopped or deleted, its agent is not installed or it has no inference key. The sender does not retry it, so the event is lost: keep the workspace running. |
| Too large | The event did not fit in what the agent can receive. |
No payload and no headers are kept. Deliveries with a bad signature are never stored, and throttled ones (rate limit, full queue, temporarily unavailable) are answered with a retry and not listed.
From the terminal: nan automations deliveries <automation>. From the API: GET /v1/automations/{id}/deliveries.
Rotate the URL or the secret
Rotate, on a webhook, gives you new credentials. Tick what to replace:
- New webhook URL: the current URL stops working at once.
- New signing secret (ticked by default): by default the old secret keeps working for 24 hours, so you can update the sender without losing events. Untick Keep the old secret valid for 24 hours if the old one leaked: it stops working at once. For Stripe, roll the secret in Stripe first and paste the new
whsec_....
The new values are shown once, like when you added the webhook.
Loops
An automation that comments on a pull request can receive a webhook for its own comment, and an automation that pushes can trigger the CI that triggers it again. NaN stops those loops:
- Everything a run does carries its mark: a
Nan-Run:line in pushed commits, a hidden marker in comments, reviews and pull requests, and thenan-run/...branches runs work on (an event on a branch with another name is recognised by the other marks). Your own scripts can forwardNAN_RUN_IDasX-Nan-Run-Id. - A webhook or schedule run is at depth 1, and an event caused by a run is one deeper than that run. Beyond the automation’s Chain depth (2 by default, 0 to 5) it is stopped and listed as Stopped: caused by one of its own runs. With 2, a webhook run can start one more run, not two.
The runs per hour of each automation, the 120 runs per hour from webhooks and schedules across your automations, and Skip the new event stop a loop that slips past the marks.
Schedules
Add a schedule
In the Triggers tab, press + Schedule:
- Schedule (cron): five fields, minute, hour, day of month, month and day of week. For example
0 9 * * 1-5(weekdays at 9:00) or30 */2 * * *(every two hours at half past). The shortcuts@hourly,@daily,@weekly,@monthlyand@yearlywork too. - Time zone: the times follow it. It starts as your browser’s.
The portal previews the next runs before you save. Press Add schedule.
Rules
- At least 15 minutes between runs. A schedule whose next runs come closer than that is refused.
- Up to 10 schedules across all your automations.
- Clock changes: when clocks go forward, a skipped hour runs at the next valid time; when they go back, a repeated hour runs once.
- A few seconds later than the minute: each schedule fires up to a minute after its time, always at the same offset, so schedules on the hour do not all start at once.
- No catching up: if a run could not start on time (during maintenance, for example) it still starts if it is at most 15 minutes late. Later than that, that time is skipped, never made up.
Last run
Each schedule shows its next run and how the last one went:
| Shown as | Meaning |
|---|---|
| Run started | A run was created. |
| Skipped: the previous run was still active | Skip the new event is on and the previous run had not ended. |
| Skipped: hourly limit reached | The automation reached its runs per hour, or you reached the 120 per hour from webhooks and schedules. |
| Skipped: too many runs waiting | Your run queue is full. |
| Skipped: automation disabled | The automation is not enabled. |
| Skipped: workspace deleted | Its workspace no longer exists. |
| Not accepted: your membership does not include runs | The run could not be created: your plan has no inference, or the workspace is stopped or not ready for the agent. |
| Missed (more than 15 minutes late) | It could not start in time and was skipped. |
| Error | Something failed on our side. |
A scheduled run receives, as data, the time it was scheduled for, the cron expression and the time zone: scheduled_for, cron and timezone.
FAQ
My GitHub webhook shows a red cross in GitHub. What happened?
Open the delivery in GitHub and look at the response. 401 means the secret in GitHub is not the signing secret of this webhook: rotate it and paste the new one. 415 means the content type is not application/json. 404 means the URL is wrong, the trigger is paused or the automation is disabled.
GitHub says 200 but no run started.
The body tells you why: ignored (the ping, or the filter did not match, often an action you did not include), not_accepted (often a stopped workspace), skipped_active, loop_suppressed or duplicate. The Deliveries tab lists the same.
Can a webhook start a run on another member's workspace?
No. A webhook URL belongs to one automation of one member, and it only starts runs of that automation, in its workspace.
Can I trigger an automation from CI without a webhook?
Yes: with a platform token that has the automations:run and runs scopes (and automations:read to call it by name), run nan automations run or call the API. See Tokens for automations.
Where do I get help?
Write in #support on Discord.