Triggers and the response
A flow has exactly one trigger, and it decides when the flow runs.
Webhook
Every flow with a webhook trigger has its own address with a token:
POST https://app.oxta.io/webhook/<flow id>/<token>
The trigger output is headers, query, body, method, path, timestamp. Read it by trigger name: {{ outputs.incoming.body.title }}.
The body is parsed by Content-Type: JSON becomes an object, application/x-www-form-urlencoded becomes an object of fields (all values are strings), anything else arrives as a plain string. If a service sends JSON with a form header (WayForPay does), the body is still parsed as JSON.
Signature check
The token in the address protects the flow only until someone learns the address. Payment systems and shops also sign every request, so the flow can accept only genuine ones. Turn it on in the Webhook trigger, in the “Signature check” block:
| Scheme | Header | How it is checked |
|---|---|---|
| Generic HMAC | you choose, X-Signature by default | HMAC of the raw body: SHA-256, SHA-1 or SHA-512, hex or base64, with or without a prefix (sha256=) |
| WooCommerce | X-WC-Webhook-Signature | base64 HMAC-SHA256 of the raw body |
| Shopify | X-Shopify-Hmac-Sha256 | base64 HMAC-SHA256 of the raw body |
| Stripe | Stripe-Signature | hex HMAC-SHA256 of timestamp.body, at most 5 minutes old |
| GitHub | X-Hub-Signature-256 | sha256= + hex HMAC-SHA256 of the raw body |
| Monobank (acquiring) | X-Sign | ECDSA P-256 with the bank's public key |
| LiqPay | signature field in the body | base64 SHA1 (or SHA3-256) of private_key + data + private_key |
| WayForPay | merchantSignature field in the body | hex HMAC-MD5 of the payment fields joined by ; |
The secret comes only from a flow variable marked as secret. The trigger stores just the variable name; the value itself is never in the node settings. A variable not marked as secret will not do.
- WooCommerce. In WooCommerce → Settings → Advanced → Webhooks, paste the webhook address and put the same value as the variable into the “Secret” field. When saving, WooCommerce sends an unsigned test request
webhook_id=…: Oxta answers it with200but does not run the flow. The flow must be switched on, otherwise it gets403and WooCommerce will not save the webhook. After five failed deliveries in a row WooCommerce disables the webhook itself, so make sure the signature passes first. - Monobank. Put the merchant token (
X-Token) into the variable: Oxta fetches the bank's public key with it and remembers it. You can also put the key from/api/merchant/pubkeyitself. If the bank rotates the key, Oxta fetches it again (at most once every 5 minutes). - Shopify. Put the app client secret into the variable. For webhooks created in Settings → Notifications → Webhooks, the key is shown there under the list.
- Stripe. Put the
whsec_…signing secret from the webhook page in Stripe into the variable, prefix included. The timestamp is signed together with the body, so a request older than 5 minutes is rejected asexpired— an intercepted request cannot be replayed. While Stripe rolls the secret the header may carry several signatures; one match is enough. - GitHub. Put the same Secret as in the repository or organisation webhook settings into the variable. The
pingGitHub sends right after the webhook is created gets200without running the flow. - LiqPay. The signature is in the
signaturebody field, not a header; the variable holdsprivate_key. LiqPay base64-encodes thedatafield, so Oxta adds the decodeddecodedto the body: read the payment status as{{ outputs.incoming.body.decoded.status }}. The LiqPay docs mention both SHA1 and SHA3-256 — both are accepted. - WayForPay. The signature is in the
merchantSignaturefield; the variable holds the merchantSecretKey. The signed fields aremerchantAccount;orderReference;amount;currency;authCode;cardPan;transactionStatus;reasonCode. WayForPay expects a signedacceptin return and retries for 4 days without it, so a valid request gets{"orderReference", "status": "accept", "time", "signature"}at once and the flow runs in the background. AResponsenode and?wait=1do not change the answer in this mode. - Other services go through generic HMAC: take the header, algorithm, encoding and prefix from the service documentation.
A request without a valid signature gets 401 with the reason in the body, and the flow does not run:
reason | What it means |
|---|---|
missing-signature | the request has no signature — neither in the header nor in the expected body field |
mismatch | the signature does not match: a different secret, or the body changed on the way |
expired | the Stripe signature is valid, but the request is older than 5 minutes |
missing-secret | the saved flow has no secret variable picked, or it is empty |
key-unavailable | the Monobank key could not be fetched — check the token |
Rejected requests do not appear in the run history, because nothing ran. The last 20 rejections are listed in the same trigger block with time and reason; the body is not stored. “Check a sample” compares a pasted body and signature without running the flow (for LiqPay and WayForPay the body is enough — the signature is inside it; an old Stripe sample shows expired). Paste the body exactly as the service sent it: one extra space already gives a different signature. Changes apply once the flow is saved.
When the caller waits for an answer
By default a webhook answers {"ok": true} at once and the flow finishes in the background. A caller that wants a real answer asks for it in the request:
| Request | What happens |
|---|---|
| no signal | {"ok": true} at once, flow continues in the background |
?wait=1 | waits for the Response node and returns it |
Prefer: wait=10 | the same through RFC 7240, with 10 seconds as the limit |
X-Oxta-Wait: 1 | the same when the address cannot be changed |
Prefer: respond-async | never wait, even with ?wait=1 |
The wait is 15 seconds by default, 30 at most. After that the caller gets 504, but the flow is not cancelled and runs to the end.
The Response node builds the answer: status, headers, body with {{expressions}}. The first response wins — with a Response on several branches, whichever ran first is returned. Put a Response on the error branch to return a meaningful failure.
Form
The Form trigger starts a flow from fields a person fills in. The node describes the fields: name, label, type (text, multiline text, number, yes/no, a choice from options, date) and whether each is required. The form opens on the flow's Run page (/flows/<id>/run) and in the test run inside the editor. Answers reach the flow as input.form.<name>, already coerced: a number is a number, a switch is true or false, a date is YYYY-MM-DD. An empty required field or an option that is not offered stops the run before it starts, and the error shows under the field. Example: a leave request with "employee", "days" and "from" → Slack to the manager → a row in Google Sheets. Only the account owner sees the form for now; public forms on sites come later.
Schedule
The cron trigger takes a five-field expression and an IANA zone:
0 9 * * 1-5 at 09:00 on weekdays
*/15 * * * * every 15 minutes
0 0 1 * * on the first of each month
Never finer than a minute. The zone defaults to UTC, so set Europe/Kyiv if you mean Kyiv.
Rate limit
Flow settings can cap how many requests pass per window. Above the cap the webhook answers 429 with Retry-After. Without a setting the platform default applies: 60 requests per minute per flow. On top of that, all webhooks of one account together may not exceed 600 requests per minute. Set a tighter limit for any flow whose address is public.