Webhooks
Receive connection events on your server or in n8n, and check each one came from Operate As.
A webhook is an address on your server, or an n8n workflow, that Operate As sends a message to whenever a client's connection changes. You can use it to pause a client's workflows or alert your team the moment access ends, before any workflow fails. Every event is listed on Notifications.
Your plan has to include webhooks. See Plans and limits.
Set it up
Add the address
On the dashboard's API & webhooks page, enter your URL under Webhook and choose Add webhook. It must be a public https:// address; an n8n Webhook trigger's production URL works well.
Copy the signing secret
It starts whsec_ and is shown only now. Keep it with your other secrets:
export OPERATE_AS_WEBHOOK_SECRET="whsec_..."Send a test
Choose Send test. We post a webhook.test event, whose data is just {"message": "It works."}, and show what your endpoint answered.
Saving makes a new secret
Your agency has one webhook. Saving it again, even with the same URL, makes a new secret and the old one stops verifying. Remove stops all deliveries.
The payload
Each delivery is a POST with Content-Type: application/json and an Operate-As-Signature header:
{
"id": "evt_3f9a1c…",
"type": "connection.expired",
"createdAt": "2026-10-16T08:00:00.123456+00:00",
"data": {
"clientId": "6f1c2a7e-…",
"clientName": "Miller Dental",
"provider": "google",
"services": ["sheets", "gmail"],
"reconnectUrl": "https://…"
}
}| Field | What it is |
|---|---|
id | Unique per event, starts evt_, and the same on a retry. Use it to skip one you've handled |
type | Which event. See Notifications |
createdAt | When it happened, ISO 8601 with a UTC offset. A retry keeps it |
data.clientId | The client's ID, as the API uses it |
data.clientName | The client's name |
data.provider | Whose connection changed. null for client.completed |
data.services | Services that connection covers. For client.completed, every service asked for |
data.reconnectUrl | The client's link, which they open to reconnect. Left out until the client has saved a passkey |
data.paused | connection.limited only. Whether the client paused the connection |
data.readOnly | connection.limited only. Whether the client allowed only reads |
api_key.new_address and api_key.refused_address are about one of your API keys, not a client. Their data holds keyId, keyName and address, the IP address the call came from.
Verify the signature
Anyone who finds your address could post to it. The signature proves a delivery came from Operate As, unchanged.
- Keep the secret: store the
whsec_…secret, shown once when you saved the webhook, in an environment variable. - Pass the raw body and header: give the library the body exactly as it arrived, before any JSON parsing, and the
Operate-As-Signatureheader. - Act only if it passes: if verification fails, answer
400and ignore the delivery. Verification also fails for a delivery signed more than five minutes ago, so an old one can't be replayed.
import os
from flask import Flask, request
from operate_as import SignatureVerificationError, Webhook
app = Flask(__name__)
@app.post("/hooks/operate-as")
def operate_as_hook():
try:
event = Webhook.verify(
request.get_data(), # the raw bytes, before JSON parsing
request.headers.get("Operate-As-Signature"),
os.environ["OPERATE_AS_WEBHOOK_SECRET"],
)
except SignatureVerificationError:
return "", 400
if event["type"] == "connection.expired":
data = event["data"]
email_client(data["clientName"], data["reconnectUrl"])
return "", 204To test your handler, each library can build a valid header: Webhook.sign (Python), OperateAs.webhooks.sign (Node), SignWebhook (Go). See Python, Node and Go.
By hand
The header is Operate-As-Signature: t=1760601600,v1=5f2b...: the signing time in Unix seconds, and a hex signature.
expected = hex(HMAC_SHA256(key = webhook_secret, message = t + "." + raw_body))
accept if constant_time_equal(expected, v1)
and abs(now_unix_seconds - t) <= 300The key is your whole secret, whsec_ included. Compare in constant time, and reject anything signed more than 300 seconds from your clock.
Delivery rules
Events are sent within a few seconds of happening. Your endpoint has 10 seconds to answer.
- Retried: no answer within 10 seconds,
429, or any5xx. The same event is sent again after 1 minute, then 2, 4, 8 and so on, never more than 2 hours apart, for about six hours in total. - Not retried: any
2xxor4xx. The delivery is done.
A retry has the same id and createdAt as the first try, and a new signature. Operate As doesn't wait on your webhook, so a slow or broken endpoint never delays your clients' connections or your API calls. Build your endpoint with these rules in mind:
| Rule | Why and how |
|---|---|
| Answer fast | Anything slower than 10 seconds counts as no answer and is sent again. Verify the signature, save the event or put it on a queue, answer 2xx, and do slow work such as emailing the client afterwards |
| Handle repeats | You can receive the same event twice, for example when your endpoint did the work but answered too late. Save each id you've handled and skip it when it comes again. Or make the action harmless to repeat: pausing a client's workflows twice leaves them paused, while sending the client an email twice sends two emails |
| Don't rely on it alone | An endpoint that's down for more than six hours misses those events for good. Dashboard notifications and emails are still sent, and API calls for a client whose access ended answer 409, so your workflows can catch it there too. See Errors |
If events seem to go missing, check the API & webhooks page first. It shows the HTTP status your endpoint gave the last delivery, or No answer if it couldn't be reached within 10 seconds.
Recipes
These use n8n, where a Webhook trigger set to POST puts each delivery's JSON under body.
Pause a client's workflows in n8n
-
Add a Webhook trigger with the method set to
POST, and save its production URL as your webhook. -
Add an If node that checks the event type is
connection.expired:{{ $json.body.type }} is equal to connection.expired -
On the true branch, save a "paused" flag for the client, for example in a data table or a sheet, keyed on their ID:
{{ $json.body.data.clientId }} -
At the start of that client's other workflows, check the flag and stop if it's set. Clear it when a
connection.connectedevent arrives for the same client.
Tell your team in Slack
After the same trigger and If node, add n8n's Slack node with your own Slack credential, and use this as the message:
{{ $json.body.data.clientName }}'s {{ $json.body.data.provider }} access ended. Send them their link: {{ $json.body.data.reconnectUrl }}Start work when a client is ready
Check for client.completed instead. It's sent once, when a client has connected everything you asked for, so it's the right moment to run their first sync or send them a welcome message.
Verifying in n8n
n8n's Webhook trigger doesn't check signatures. To check them, turn on Raw Body in the trigger's options and follow the steps in By hand in a Code node, using Node's crypto module. Some n8n setups block crypto in Code nodes. If yours does, keep the webhook URL private, since anyone who has it can post to it.