# 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:

```json
{
  "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

1. Generate a long random secret, for example with `openssl rand -hex 32`.
2. In Torii, open **Workflows**, create a workflow and choose its trigger.
3. Add the **Custom action (HTTP Request)** step. Leave **Use a connected integration's credentials** set to **No**, since you're calling your own endpoint.
4. Choose the HTTP method. `POST` is the simplest choice for sending events.
5. Enter your HTTPS endpoint as the URL.
6. Under headers, add a key/value pair for your secret, for example `Authorization` with `Bearer <secret>`.
7. Add the JSON body, including a field that names the workflow.
8. 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](/docs/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.

```javascript
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:

```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](/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 `workflow` or `event` field in every body, and keep the names consistent across workflows.
* Or give each workflow its own URL path.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/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](/docs/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](/docs/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](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Verify, store, respond, then do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Torii webhooks reliably in minutes.