Incoming webhooks¶
The optional Orchestra Webhook submodule (orchestra_webhook) gives
outside services an address to call when something happens on their side: a
contract signed in an e-signature service, a record approved in a CRM, a
delivery made by a carrier. Each call is checked against a signature, then
delivered as an event to the steps waiting for it.
A webhook is one such address. It delivers one event, in one tenant. Its format knows how its sender signs a call, and where the call says what it is about. It is configured under Administration » Configuration » Workflow » Orchestra » Webhooks, by users with the Administer Orchestra webhooks permission.
Payments do not come through a webhook
A payment taken in a workflow goes through Payment: the payment engine hears its provider, settles the payment, and resumes the step. A webhook is for everything else a service reports.
An example: a contract signed¶
A contract is sent for signature from a workflow started with the contract number as its correlation key. The workflow waits for the signature, for at most two weeks:
flowchart LR
s([Start]) --> p["Send the contract for signature"]
p --> w["Wait for the signature<br/>type: event"]
w -->|signature.status == signed| d([Signed])
w -->|signature == expired| c["Call the client<br/>type: user"]
n_wait_signature:
type: event
config:
event: contract_signed
payload_variable: signature
features:
timeout:
deadline: relative
deadline_settings:
duration: P14D
action: resume
settings:
timeout_outcome: expired
Without a signature within two weeks, the timeout resumes the
step with signature == expired, and the second flow calls the client.
The contract system tells Orchestra when a contract is signed. It is written for Orchestra, so its webhook uses the Orchestra format:
| Setting | Value |
|---|---|
| Name | Contract signatures |
| Tenant | the tenant the contracts are in |
| Event | contract_signed |
| Delivery | Deliver to one waiting step |
| Keep an early event for | 3600 seconds |
| Format | Orchestra |
| Secret | a key holding the secret shared with the contract system |
Saved, the webhook shows its address, such as
https://example.com/orchestra-webhook/contract_signatures. The contract
system calls it like this:
POST /orchestra-webhook/contract_signatures HTTP/1.1
Content-Type: application/json
X-Orchestra-Timestamp: 1761900000
X-Orchestra-Signature: Kq2v0Y1Z5mC8x3Rk7uJp9dLs4bNw6eHf2gTa1oVi8cE
{"correlation_key": "CTR-2026-0042", "event_id": "sig-58213", "data": {"status": "signed", "signed_by": "Jane Doe"}}
The webhook checks the signature, reads CTR-2026-0042 as the correlation key
and sig-58213 as the event id, and delivers contract_signed with data as
its data. The step resumes, signature holds data, and the flows route on
signature.status. When the contract system sends the same call again, it
is answered as heard and resumes nothing.
Settings¶
| Setting | What it does |
|---|---|
| Tenant | The events reach only the instances of this tenant. |
| Event | The machine name of the event every call delivers. A webhook set up for signatures can never deliver anything else. |
| Delivery | Deliver to one waiting step resumes the one step waiting for the event, and keeps the event when its step is not waiting yet. Broadcast to every waiting step resumes every step waiting for it, and keeps nothing. See Deliver, or broadcast. Start the workflows starting on it starts an instance of each workflow of the tenant whose start node starts on the event, and resumes nothing. See Starting a workflow on an event. |
| Keep an early event for | How long a delivered event is kept for an instance whose step is not waiting yet. 0 keeps nothing. The engine keeps an event a week at most. |
| Format | How the sender signs its calls, and where it says what they are about. A format may ask for settings of its own. See Formats. |
| Secret | The key, held by the Key module, with the secret the calls are signed with. Never stored in configuration, never exported. |
| Previous secret | During a rotation, the key with the secret being replaced. See Rotating a secret. |
| Largest call | A signed call larger than this is refused. A call over 64 KB, the most an event may carry, is refused before anything else, whichever webhook it names. |
| Enabled | A disabled webhook refuses every call. |
The address is /orchestra-webhook/{machine name}, called with POST.
Formats¶
A format is what the sender fixes: how it signs a call, where it puts the
event id, where it puts the reference the call is about, and what of the call
is worth delivering. The bearer token format delivers the body as it came; the
Orchestra format and a provider's format narrow it, so a workflow reads the
same fields whatever the sender wraps them in. Every call is
signed with a secret the webhook shares with its sender, and signatures are
compared in constant time. Two formats ship with the module; the optional
Orchestra Webhook Examples submodule (orchestra_webhook_examples) adds
Stripe's and GitHub's, written to be read as examples of a provider format.
Orchestra¶
For a sender written for Orchestra. The body is a JSON envelope holding everything the call is about:
{"correlation_key": "CTR-2026-0042", "event_id": "sig-58213", "data": {"status": "signed"}}
| Field | Value |
|---|---|
correlation_key |
The business reference the instance was started with. |
event_id |
Optional: an id of the call's own, so the same call sent twice is refused. |
data |
What the event delivers to the step. |
The sender signs orchestra_webhook.call, a dot, the timestamp, a dot and that
body with HMAC-SHA256, the way Orchestra sites sign what they send each other,
and sends two headers:
| Header | Value |
|---|---|
X-Orchestra-Timestamp |
The Unix time the call was signed. |
X-Orchestra-Signature |
The HMAC-SHA256 of orchestra_webhook.call.{timestamp}.{body}, in URL-safe base64 without padding. |
Everything that decides which instance a call reaches, and whether it is a duplicate, is inside what is signed: a captured call cannot be sent on to another instance, and a call signed more than five minutes away from this site's clock is refused. The format has no settings.
Sending a call from a shell:
body='{"correlation_key":"CTR-2026-0042","event_id":"sig-58213","data":{"status":"signed","signed_by":"Jane Doe"}}'
timestamp=$(date +%s)
signature=$(printf 'orchestra_webhook.call.%s.%s' "$timestamp" "$body" \
| openssl dgst -sha256 -hmac "$SECRET" -binary \
| base64 | tr '+/' '-_' | tr -d '=')
curl https://example.com/orchestra-webhook/contract_signatures \
-H 'Content-Type: application/json' \
-H "X-Orchestra-Timestamp: $timestamp" \
-H "X-Orchestra-Signature: $signature" \
--data "$body"
From plain PHP, with nothing but the curl extension:
$body = json_encode([
'correlation_key' => 'CTR-2026-0042',
'event_id' => 'sig-58213',
'data' => ['status' => 'signed', 'signed_by' => 'Jane Doe'],
]);
$timestamp = time();
// HMAC-SHA256, base64url without padding.
$signature = rtrim(strtr(base64_encode(hash_hmac('sha256', 'orchestra_webhook.call.' . $timestamp . '.' . $body, $secret, TRUE)), '+/', '-_'), '=');
$curl = curl_init('https://example.com/orchestra-webhook/contract_signatures');
curl_setopt_array($curl, [
CURLOPT_POST => TRUE,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Orchestra-Timestamp: ' . $timestamp,
'X-Orchestra-Signature: ' . $signature,
],
// The exact bytes that were signed.
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => TRUE,
]);
$answer = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
From Node.js 18 or later, with no package:
import { createHmac } from 'node:crypto';
const body = JSON.stringify({
correlation_key: 'CTR-2026-0042',
event_id: 'sig-58213',
data: { status: 'signed', signed_by: 'Jane Doe' },
});
const timestamp = String(Math.floor(Date.now() / 1000));
// HMAC-SHA256, base64url without padding.
const signature = createHmac('sha256', secret)
.update(`orchestra_webhook.call.${timestamp}.${body}`)
.digest('base64url');
const response = await fetch('https://example.com/orchestra-webhook/contract_signatures', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orchestra-Timestamp': timestamp,
'X-Orchestra-Signature': signature,
},
// The exact string that was signed.
body,
});
Whatever the language, three things make a call pass: sign exactly
orchestra_webhook.call.{timestamp}.{body}, encode the signature as base64url
without padding, and send the very bytes that were signed. A body serialized
again after signing, even with the same content, no longer matches.
On a Drupal site, the classes Orchestra signs with build the same request:
use Drupal\orchestra\OrchestraSignature;
use Drupal\orchestra\SignedPayload;
use Drupal\orchestra\SignedRequest;
use Drupal\orchestra_webhook\Plugin\WebhookFormat\OrchestraFormat;
use GuzzleHttp\Psr7\Request;
$signature = OrchestraSignature::sign(new SignedPayload(OrchestraFormat::PURPOSE, $body), $secret, time());
$request = new Request('POST', 'https://example.com/orchestra-webhook/contract_signatures', ['Content-Type' => 'application/json'], $body);
\Drupal::httpClient()->send(SignedRequest::attach($request, $signature));
Bearer token (weak)¶
For a service that can sign nothing: it sends the secret itself, in an
Authorization: Bearer {secret} header. Nothing is signed, so whoever sees one
call can send others, or change what it says. The webhook form warns when it
is saved with this format. Choose a signature whenever the service offers one.
Such a service has no format of its own, so the webhook says where its calls carry what they are about:
| Setting | What it does |
|---|---|
| Correlation key read from, field or header | A field of the JSON body, or a header. A nested field is named with dots: data.object.reference. |
| Event id read from, field or header | Where each call carries an id of its own, so the same call sent twice is refused. Optional, and recommended: services retry. |
Stripe (example)¶
Stripe signs {timestamp}.{body} with HMAC-SHA256 in hexadecimal, in the
Stripe-Signature header. Calls more than five minutes old are refused. While
Stripe rolls a secret it signs with both, and either is accepted. The event id
is Stripe's own id field. The reference goes in the object's metadata, under
a key the integration chose when it created the object, so that key is the
format's one setting:
| Setting | Value |
|---|---|
| Metadata key | contract, read as data.object.metadata.contract |
The event delivers the object the call is about and Stripe's event type, not
Stripe's envelope. With the step's payload variable set to invoice:
{"type": "invoice.paid", "object": {"amount_paid": 4200, "metadata": {"contract": "CTR-2026-0042"}}}
and a flow routes on invoice.object.amount_paid.
GitHub (example)¶
GitHub signs the body with HMAC-SHA256 in hexadecimal, in the
X-Hub-Signature-256 header, after sha256=. The format reads the event id
from GitHub's X-GitHub-Delivery header, so GitHub's own redelivery of a call
is refused as a duplicate. GitHub signs neither that header nor a time,
though: whoever captures a call can send it again later under a new delivery
id, and it is heard. Keep a GitHub webhook to events that are safe to receive
twice. What a call is about depends on the event, so the
field holding the reference is the format's one setting:
| Setting | Value |
|---|---|
| Correlation key field | pull_request.head.ref |
The event delivers GitHub's event name, which only the X-GitHub-Event header
carries, beside the body: {"event": "pull_request", "payload": {…}}. With
the step's payload variable set to github, a flow routes on
github.event == pull_request.
Writing a format¶
Another provider's format is a small plugin in your own module, in
src/Plugin/WebhookFormat. The two examples are the place to start: copy the
one closest to your provider. A format answers three questions: was this call
signed with this secret, which event id does it carry, and which reference is
it about. It may answer a fourth, getEventData(), to deliver less than the
whole body, or something only a header carries.
namespace Drupal\my_module\Plugin\WebhookFormat;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\orchestra\HmacSignature;
use Drupal\orchestra_webhook\Attribute\WebhookFormat;
use Drupal\orchestra_webhook\WebhookFormatBase;
use Symfony\Component\HttpFoundation\Request;
#[WebhookFormat(
id: 'my_service',
label: new TranslatableMarkup('My service'),
description: new TranslatableMarkup('My service signs the body with HMAC-SHA256 in base64, in the X-My-Signature header.'),
)]
final class MyServiceFormat extends WebhookFormatBase {
public function verify(Request $request, string $body, string $secret, int $now): bool {
$signature = (string) $request->headers->get('X-My-Signature', '');
return HmacSignature::hasValidSignature($body, [$signature], $secret, 'sha256', HmacSignature::ENCODING_BASE64);
}
public function getEventId(Request $request, array $data): ?string {
return self::readField($data, 'delivery.id');
}
public function getCorrelationKey(Request $request, array $data): ?string {
return self::readField($data, 'resource.reference');
}
}
Read the reference and the event id from what the provider signs whenever it lets you: a value in an unsigned header can be changed by whoever captures a call, and the call then reaches another instance, or passes as a new one.
WebhookFormatBase reads values with readHeader() and readField(),
delivers the body as it came, and gives a format with no settings an empty
form. A format with settings declares
them in defaultConfiguration() and buildConfigurationForm(), validates them
in validateConfigurationForm(), and types them in its module's config schema
as orchestra_webhook.format_settings.{format id}. The webhook depends on the
module providing its format, so uninstalling that module removes its webhooks.
The HMAC itself, in any hash hash_hmac_algos() lists and in hexadecimal,
base64 or URL-safe base64, is \Drupal\orchestra\HmacSignature::hasValidSignature():
it refuses an empty secret and compares in constant time. A format that signs a
timestamp also checks it with HmacSignature::isRecent(), with its own
tolerance. A format signed with a public key rather than a shared secret keeps
the key in the webhook's Key and checks it with openssl_verify(). Set
weak: TRUE on a format that sends the secret itself.
A format that signs the way Orchestra does, with a purpose of its own, checks
the call with OrchestraSignature and reads the headers with SignedRequest,
as the Orchestra format does:
public const string PURPOSE = 'my_module.order_call';
public function verify(Request $request, string $body, string $secret, int $now): bool {
return OrchestraSignature::verify(new SignedPayload(self::PURPOSE, $body), SignedRequest::read($request), $secret, $now);
}
The purpose is signed with the body, so a call signed for one purpose never passes for another under the same secret. It starts with the machine name of the module that defines it, so two modules never pick the same one.
Deliver, broadcast, or start¶
A webhook that delivers needs one step waiting for its event, in the running instances carrying the correlation key. Several instances may carry the key, a parent and its child for example: the event goes to the one step waiting for it. A call that two waiting steps could take is refused: which one it is for is the sender's to say.
A webhook that broadcasts resumes every step waiting for its event, in every running instance carrying the key. It suits news meant for everyone concerned. For example, a supplier announcing that a catalog changed, while each open quote for that catalog waits to re-price:
| Setting | Value |
|---|---|
| Event | catalog_updated |
| Delivery | Broadcast to every waiting step |
| Format | Orchestra, the catalog sent as the envelope's correlation_key |
Each quote is started with the catalog as its correlation key, and parks on a
step waiting for catalog_updated. One call resumes all of them. A broadcast
is never kept: a quote reaching its step later waits for the next update.
A webhook that starts starts an instance of each workflow of its tenant starting on its event, known by the call's correlation key when it carries one. A call without a key is accepted: the instances then carry none.
What the sender is told¶
| Answer | When |
|---|---|
200 {"result": "resumed", "steps": 1} |
The event resumed that many steps. |
202 {"result": "kept"} |
No step waited yet, and the event is kept. |
200 {"result": "started", "instances": 2} |
The event started that many instances. |
200 {"result": "duplicate"} |
The event id was already delivered or kept. Answered as a success, so the sender stops retrying. |
422 {"result": "no_instance"} |
No running instance in the tenant carries the correlation key. |
422 {"result": "several_waiting"} |
Several steps wait, and the webhook delivers to one. |
422 {"result": "no_workflow"} |
The webhook starts, and no workflow of the tenant starts on its event. |
422 {"result": "not_unique"} |
The webhook starts, and each workflow starting on its event already runs an instance with the same start data. |
422 {"result": "not_waiting"} |
No step waits, and the webhook broadcasts or keeps nothing. |
400 {"result": "invalid"} |
The body is not JSON, a webhook that delivers or broadcasts gets a call with no correlation key where its format reads it, the correlation key is longer than 255 characters, or the workflows starting on the event cannot store its data. |
413 {"result": "too_large"} |
The signed call is larger than the webhook accepts. |
413, empty |
The call is over 64 KB, the most an event may carry. Answered before the webhook is even looked up. |
403, empty |
The call is not signed by the webhook's sender, or the webhook is unknown or disabled. |
429 |
Too many calls; see Request limits. |
Providers retry a call answered with anything but a 2xx. A 422 for
no_instance is usually worth a retry: the instance may not have been
started yet.
Security¶
- Every call is signed, and the secret is held by the Key module. The key a webhook's secret is in cannot be deleted while the webhook names it: the key's delete form says which webhook uses it.
- No replay. The Orchestra and Stripe formats refuse a call signed more than five minutes ago, and sign the reference and the event id with the body, so a captured call can be sent neither later nor elsewhere. The engine refuses an event id it has already delivered or kept, for a day. GitHub signs no time and no delivery id, so a GitHub call can be replayed; see its format.
- No probing. Everything answered before the signature is the same for
every webhook name: a call that is not signed, a webhook that does not exist
and a disabled one all get the same empty
403, and a call over 64 KB the same413. Only a caller holding the secret learns the webhook's own size limit, or whether a correlation key matched. - Narrow reach. A webhook delivers only its own event, in its own tenant. The engine then lets the event reach only steps waiting for it.
- Data only. The body is JSON, bounded in size, written to the step's payload variable, whole or field by field, and never evaluated.
- No leaks. No session, no cookie, nothing cached. A call a webhook refuses is logged once per sender and period, naming the webhook, never the secret or the body. A call naming no webhook, or a disabled one, is not logged, so nobody can write into the log by making up names.
Rotating a secret¶
- Create a key with the new secret.
- On the webhook, set Previous secret to the current key, and Secret to the new one. Calls signed with either are heard.
- Give the new secret to the sender.
- Once the sender signs with it, empty Previous secret, or delete the old key: the webhook lets it go and keeps the new one. The key of the current secret cannot be deleted.
Request limits¶
Calls are counted before anything else is done, per webhook and sending
address, and across all webhooks. The limits are set on the Request limits
tab of the webhook list, also Orchestra › Webhook request limits, and apply
once request limits are turned on for the whole site on the Orchestra request
limits page. Both ship at 0, which is no limit. A call over a limit is
answered 429.