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:

SchemeHeaderHow it is checked
Generic HMACyou choose, X-Signature by defaultHMAC of the raw body: SHA-256, SHA-1 or SHA-512, hex or base64, with or without a prefix (sha256=)
WooCommerceX-WC-Webhook-Signaturebase64 HMAC-SHA256 of the raw body
ShopifyX-Shopify-Hmac-Sha256base64 HMAC-SHA256 of the raw body
StripeStripe-Signaturehex HMAC-SHA256 of timestamp.body, at most 5 minutes old
GitHubX-Hub-Signature-256sha256= + hex HMAC-SHA256 of the raw body
Monobank (acquiring)X-SignECDSA P-256 with the bank's public key
LiqPaysignature field in the bodybase64 SHA1 (or SHA3-256) of private_key + data + private_key
WayForPaymerchantSignature field in the bodyhex 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 with 200 but does not run the flow. The flow must be switched on, otherwise it gets 403 and 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/pubkey itself. 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 as expired — 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 ping GitHub sends right after the webhook is created gets 200 without running the flow.
  • LiqPay. The signature is in the signature body field, not a header; the variable holds private_key. LiqPay base64-encodes the data field, so Oxta adds the decoded decoded to 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 merchantSignature field; the variable holds the merchant SecretKey. The signed fields are merchantAccount;orderReference;amount;currency;authCode;cardPan;transactionStatus;reasonCode. WayForPay expects a signed accept in 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. A Response node and ?wait=1 do 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:

reasonWhat it means
missing-signaturethe request has no signature — neither in the header nor in the expected body field
mismatchthe signature does not match: a different secret, or the body changed on the way
expiredthe Stripe signature is valid, but the request is older than 5 minutes
missing-secretthe saved flow has no secret variable picked, or it is empty
key-unavailablethe 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:

RequestWhat happens
no signal{"ok": true} at once, flow continues in the background
?wait=1waits for the Response node and returns it
Prefer: wait=10the same through RFC 7240, with 10 seconds as the limit
X-Oxta-Wait: 1the same when the address cannot be changed
Prefer: respond-asyncnever 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.