Guide to Torii Webhooks: Features and Best Practices
Torii webhooks carry SaaS management activity out of Torii: a new hire joins, someone leaves, a new app is discovered or a licence goes unused, and a Torii workflow tells another system about it. If your organisation manages its SaaS stack in Torii, these requests are how workflow results reach your own services.
This guide covers how Torii sends requests from a workflow, how to authenticate them with a secret header, how to design a body your receiver can route on, and the practices that make a Torii receiver dependable.
What are Torii webhooks?
Torii doesn't have a list of webhook events you subscribe to. Instead, a workflow runs when its trigger fires, and one of its steps can be a Custom action (HTTP Request). Torii's documentation lists "Sending a webhook from Torii" among the things this action is for.
The person building the workflow decides almost everything about the request: the method, the URL, query parameters, headers and the JSON body. Values from the workflow are inserted with @ placeholders. Torii adds no event name, no envelope and no signature. To authenticate the request, you add a secret header to the action and check it on your endpoint.
Torii webhook features
| Feature | Details |
|---|---|
| Where it's configured | A workflow step: Custom action (HTTP Request) |
| When it fires | When the workflow's trigger fires and the workflow reaches the step |
| Method | Chosen by the workflow author |
| Body | Optional JSON, written by the author with @ placeholders |
| Content type | application/json by default. application/x-www-form-urlencoded or multipart/form-data if you change the Content-Type header |
| Event identifier | None, unless you put one in the body |
| Authentication | A header you add as a key/value pair. No signature |
| Testing | Run now on the workflow |
| Retries and timeouts | Not documented |
| Source IPs | Not published |
Common events
Because the body is yours, the useful list is Torii's workflow triggers. Torii documents two kinds:
| Kind | Triggers |
|---|---|
| State-based, evaluated when Torii next syncs data from integrations | User meets criteria, User joins, User left, User attribute changed, App meets criteria, New app discovered, Closed app in use, License not in use, User stopped using app, License Count Threshold, Contract meets criteria, Scheduled Action |
| Event-based, run as soon as the event happens | App event, Custom trigger (Webhook) |
State-based triggers run on Torii's sync, so a "User left" workflow can run some time after the change in the source system. Torii notes that for Google or Okta as the source of truth, "User left" workflows trigger within an hour, and recommends the App event trigger for real-time offboarding.
Since Torii sends no event name, write one into the body yourself. A body like this gives your receiver something stable to route on:
{
"workflow": "user_offboarding",
"trigger": "User left",
"user_email": "<user email placeholder>",
"sent_by": "torii"
}
In the body editor, type @ to insert a placeholder in place of <user email placeholder>. The placeholders on offer depend on the trigger, so pick them in Torii rather than copying names from elsewhere.
Setting up Torii webhooks
- Generate a long random secret, for example with
openssl rand -hex 32. - In Torii, open Workflows, create a workflow and choose its trigger.
- Add the Custom action (HTTP Request) step. Leave Use a connected integration's credentials set to No, since you're calling your own endpoint.
- Choose the HTTP method.
POSTis the simplest choice for sending events. - Enter your HTTPS endpoint as the URL.
- Under headers, add a key/value pair for your secret, for example
AuthorizationwithBearer <secret>. - Add the JSON body, including a field that names the workflow.
- Click Run now on the workflow to test it.
If you change the Content-Type header to application/x-www-form-urlencoded or multipart/form-data, keep writing the body as JSON. Torii sends the fields in the selected format.
In Hookdeck. Create a source with the Torii type, enter the header key in API Key Header and the exact header value in API Key, including any Bearer prefix, and use the source URL in the action. Hookdeck matches the header name case-insensitively and the value exactly. The source type pre-selects POST but doesn't manage the method, so if your action uses PUT, PATCH, GET or DELETE, allow that method on the source too. Don't use OPTIONS: Hookdeck answers it as a CORS preflight and never turns it into an event.
For local development, run hookdeck listen 3000 torii --path /webhooks/torii with the Hookdeck CLI to get a public URL that forwards to your machine, with no account required.
Securing Torii webhooks
Look up your header by name, case-insensitively, and compare its whole value with the secret in constant time.
const crypto = require('crypto');
const express = require('express');
const app = express();
function safeEqual(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on buffers of different lengths
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function verifyToriiRequest(req) {
const secret = process.env.TORII_WEBHOOK_SECRET; // e.g. "Bearer 7c1e..."
if (!secret) return false; // fail closed when unconfigured
const name = process.env.TORII_WEBHOOK_HEADER || 'Authorization';
const received = req.get(name); // case-insensitive lookup
return typeof received === 'string' && safeEqual(received, secret);
}
// Accept the methods your workflows use.
app.all('/webhooks/torii', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyToriiRequest(req)) return res.sendStatus(401);
let payload = {};
const text = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '';
if (text) {
try {
payload = JSON.parse(text);
} catch {
return res.sendStatus(400);
}
}
// Route on the field you put in the body, e.g. payload.workflow.
res.sendStatus(200);
});
The same check in Python:
import hmac
import os
def verify_torii_request(headers) -> bool:
secret = os.environ.get("TORII_WEBHOOK_SECRET")
if not secret:
return False # fail closed
name = os.environ.get("TORII_WEBHOOK_HEADER", "Authorization")
received = headers.get(name) # case-insensitive in Starlette and Flask
if not received:
return False
return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))
A matching header proves the sender knows the secret. It doesn't prove the body is unchanged, and a captured request can be replayed later, because Torii sends no signature or timestamp. Use HTTPS, a long random secret, and rotate it in the action and your receiver together if it may have leaked. Torii also supports query parameters, but a secret in the URL tends to end up in logs, so prefer the header.
Make Torii webhooks production-ready. Hookdeck Event Gateway checks your secret header on every Torii request, stores each one durably, and retries delivery to your service on a schedule you control.
Torii webhook limitations and pain points
No event name unless you add one
The Problem: Requests from different workflows look alike, and the receiver can't tell which trigger sent them.
Why It Happens: Torii sends the body the author wrote and adds nothing of its own.
Workarounds:
- Put a fixed
workfloworeventfield in every body, and keep the names consistent across workflows. - Or give each workflow its own URL path.
How Hookdeck Can Help: Hookdeck filters can route on any body field or on the path, so one Torii source can send each workflow's requests to a different destination.
A static header, not a signature
The Problem: Anyone who learns the header value can send convincing requests, for example a fake offboarding.
Why It Happens: The header is a fixed value the author typed. Nothing is computed over the body.
Workarounds:
- Use a long random value, HTTPS only, and keep it out of logs.
- For destructive actions such as removing access, re-check the user or app in Torii's API before acting.
How Hookdeck Can Help: Hookdeck's Torii source compares the header you name with the exact value you enter. Hookdeck answers Torii with a 200 either way, then records a request without the right header as failed verification, so it never reaches your destination.
Delivery behaviour isn't documented
The Problem: If your endpoint is down when the workflow runs, you can't be sure the request is tried again.
Why It Happens: Torii's custom action documentation doesn't describe retries, timeouts or how a failed request affects the workflow.
Workarounds:
- Keep the endpoint highly available and respond quickly.
- After an outage, check the workflows that should have run and run them again by hand where needed.
How Hookdeck Can Help: Hookdeck accepts the request as soon as it arrives, so a short outage on your side doesn't depend on Torii trying again. Hookdeck then retries delivery to your service and lets you replay any request.
Methods and content types vary by workflow
The Problem: A receiver that only accepts JSON POST requests rejects a workflow that someone built with PUT, or with a form-encoded body.
Why It Happens: Each custom action chooses its own method and Content-Type.
Workarounds:
- Agree on one method and JSON for every workflow that calls your endpoint.
- If you must accept more, read the raw body and branch on
Content-Type.
How Hookdeck Can Help: Hookdeck accepts the methods you allow on the source and stores every request with its method, headers and body, so a workflow that sends something unexpected is easy to spot. Transformations can normalise the shape before delivery.
Best practices
Store the whole header value
Compare against the exact value you typed in Torii, prefix included. Stripping Bearer on one side and not the other rejects every request.
Name the workflow in the body
A stable workflow field is the only routing key you get. Treat it as a contract and change it deliberately.
Make handlers idempotent
State-based triggers can match the same user or app again after a sync, and you may re-run workflows by hand. Include an ID such as the user's email or app ID in the body and deduplicate on it. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
Verify, store, respond, then do the work. See why to process webhooks asynchronously.
Conclusion
Torii sends webhooks from a workflow's Custom action (HTTP Request), with the method, headers and body all chosen by the person building the workflow. Add a secret header and compare it in constant time over HTTPS, write a workflow name into the body, allow only the methods your workflows use, and make handlers idempotent.
Hookdeck Event Gateway sits in front of your receiver, checks the secret header, keeps every request, and retries delivery to your service, which covers the delivery behaviour Torii doesn't document.
Get started with Hookdeck for free and handle Torii webhooks reliably in minutes.