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://…"
  }
}
FieldWhat it is
idUnique per event, starts evt_, and the same on a retry. Use it to skip one you've handled
typeWhich event. See Notifications
createdAtWhen it happened, ISO 8601 with a UTC offset. A retry keeps it
data.clientIdThe client's ID, as the API uses it
data.clientNameThe client's name
data.providerWhose connection changed. null for client.completed
data.servicesServices that connection covers. For client.completed, every service asked for
data.reconnectUrlThe client's link, which they open to reconnect. Left out until the client has saved a passkey
data.pausedconnection.limited only. Whether the client paused the connection
data.readOnlyconnection.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.

  1. Keep the secret: store the whsec_… secret, shown once when you saved the webhook, in an environment variable.
  2. Pass the raw body and header: give the library the body exactly as it arrived, before any JSON parsing, and the Operate-As-Signature header.
  3. Act only if it passes: if verification fails, answer 400 and 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 "", 204

To 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) <= 300

The 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 any 5xx. 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 2xx or 4xx. 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:

RuleWhy and how
Answer fastAnything 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 repeatsYou 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 aloneAn 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

  1. Add a Webhook trigger with the method set to POST, and save its production URL as your webhook.

  2. Add an If node that checks the event type is connection.expired:

    {{ $json.body.type }}  is equal to  connection.expired
  3. 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 }}
  4. At the start of that client's other workflows, check the flag and stop if it's set. Clear it when a connection.connected event 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.

On this page