Trigger

A Trigger is one source node that watches an external service — Gmail, Slack, Jira, Notion and twenty more — and fires a pipeline whenever it emits an event. n8n ships a separate node per service; HostWebhook ships one node with a service selector, so adding a new service is a dropdown option, not a new concept to learn.

Gmail: new email--▶Trigger--▶AI Node / Filter / anything

Services

Twenty-eight today — Gmail, Google Calendar, Google Drive, Google Sheets, Jira, Telegram, Discord, Slack, RSS, Social Media, Mailchimp, Notion, Shopify, GitHub, Calendly, Airtable, WooCommerce, WhatsApp, Outlook, Outlook Calendar, OneDrive, Excel, Word, Microsoft Teams, Stripe, HubSpot, Todoist and Postgres. Same node, different service value.

Some services have their trigger documented on their own page, next to the action node: Calendly, GitHub, Mailchimp, Shopify, WooCommerce, WhatsApp, Outlook, Outlook Calendar, OneDrive, Excel, Word, Microsoft Teams, Microsoft To Do, SharePoint, Stripe, HubSpot and Todoist.

How the event reaches us is not the same for all of them, and it is worth knowing which you are picking:

  • Push — the service calls us the moment something happens. Gmail (via Google Cloud Pub/Sub), Calendar, Drive, Sheets, Jira, Telegram, Discord, Slack, Mailchimp, Shopify, GitHub, Calendly, Airtable, WooCommerce, WhatsApp, Outlook, Outlook Calendar, OneDrive, Word and Microsoft Teams (via a Microsoft Graph subscription — OneDrive also checks on its own every 30 minutes and Word about every 15, or every 5 when Microsoft won't take the subscription). Activating registers the subscription for you.
  • Polling — HostWebhook checks on a schedule, because the service offers no subscription we can register on your behalf. Notion, Postgres, Social Media, RSS when the feed has no WebSub hub, and Excel — Microsoft doesn't notify about changes inside a workbook, so HostWebhook checks the file on a schedule. None of these checks more often than every 5 minutes on a paid plan, or every 15 minutes on Free. Activating starts the schedule. The Social Media trigger (Mastodon, Bluesky and the other networks) takes its interval in minutes, hours or days — up to 7 days — and can skip old posts with Only posts from the last N minutes, hours or days (up to 30 days): a post older than that never fires, even the first time the trigger sees it. It works together with Results per poll.
ℹPolling is not a shortcut we took. Notion, for instance, does have webhooks — but their subscriptions are created by hand in Notion's developer portal and verified by pasting a token back, so there is no API to register one per user. Zapier polls Notion for the same reason.
ℹEvery external service follows the same pattern: OAuth in, subscribe to events, receive webhooks. Making them separate node types would force the user to learn each one from scratch. With one node, the UX is: pick a service, pick a credential, configure filters, activate.

How it looks on the canvas

Once a service is picked, the card wears that service's official logo with a small plug badge over its corner — the logo says which service, the plug says it is a trigger and not an action node of the same brand. Under the destination line, one line sums up the configuration in the service's colour: the operations or event types it fires on, the labels, calendar or folder it watches, and the time of the last event received. The quick-view panel shows the same mark next to the title, a chip with the service name, and the logo in its Service row. A trigger with no service yet shows the plug alone.


Gmail setup

Gmail inbound uses Google Cloud Pub/Sub — Google publishes a message every time the watched mailbox changes, HostWebhook reads what changed through the Gmail API, applies this trigger's event, labels and filter, and fires your pipeline once per matching change with a structured payload.

1. Connect your Google account

Drop a Trigger on the canvas, open it and pick Gmail as the service. Then pick a Google credential, or connect one right there — it needs Full Gmail access. Under the hood this requests the gmail.modify + gmail.compose + gmail.send scopes so the trigger can read the changes, fetch full messages and download attachments.

⚠A legacy Gmail credential with only gmail.send scope can't be used for triggers — it lacks read permissions. Re-connect with “Full Gmail access” when the picker prompts.

2. Pick what fires it

The form asks one thing at a time, starting with the Event Trigger — nothing is picked for you. Eight events, one per trigger:

  • New email — a new message arrives in one of the selected labels.
  • New labeled email — one of the selected labels is added to a message. Only that change counts: starring or reading a message that already had the label does not fire it.
  • New starred email — you star a message, any message, mail you sent included. It watches the STARRED label, so there are no labels to pick.
  • New attachment — a new message that carries at least one real file. Images embedded in the body, like a signature logo, don't count.
  • New thread — only the first message of a conversation, not the replies.
  • New email matching search — a new message that matches the Search you write (the same syntax as the Query filter; here it is the point of the trigger, not an extra).
  • Deleted — a message is moved to Trash, or deleted for good (Delete forever, emptying the Trash, Gmail's 30-day purge), from any label, mail you sent included, so there are no labels to pick. Drafts don't count: Gmail deletes and recreates a draft every time it saves one. For the same reason, a deletion for good that Gmail reports without the message's labels is skipped — it can't be told apart from a draft being saved — and the trigger's log says so once. Moving to Trash isn't affected. With a Query filter set, deletions for good are skipped too: nothing is left of them to match.
  • Label removed — one of the selected labels is removed from a message. It fires even though the message no longer has the label — that is the change — and removing any other label, such as marking the message as read, does not.

Each change counts once. For the label and delete events every change is its own event: unstar a message and star it again and the trigger fires again, while the same change delivered twice by Google fires only once. The new-message events fire once per message.

3. Labels

The account's labels appear as chips, split into System (Inbox, Unread, Starred, Important, Sent…) and Custom (yours) — you never type an id like Label_12345. A label created in Gmail after the page opened shows up with the refresh button next to the title. What the selection means depends on the event:

  • New email, New attachment, New thread and New email matching search — where to watch. A message in any of the selected labels counts. Nothing selected means INBOX.
  • New labeled email and Label removed — which labels have to be added or removed. Nothing selected means any label, on a message that is in the Inbox: archiving a message takes it out of the Inbox, so it doesn't count as a label removed.
  • New starred email and Deleted don't ask, and ignore any labels they have. A Deleted trigger saved when the form still asked for labels keeps them until its next save, which drops them; until then they change nothing — not what fires it, not what the watch covers — and the canvas card doesn't show them.

Your own mail in Sent — in Sent and not in Inbox — is skipped by the new-message events (New email, New attachment, New thread, New email matching search) unless you select the Sent label. The other four fire on it too: labeling, starring or deleting a message you sent fires them like any other. See No loops.

A selected label that has since been deleted in Gmail stays visible under Selected but not in your Gmail, so the form never drops it without telling you. Changing the event or the credential clears the labels, because they meant something else.

4. Query filter

Optional — except on New email matching search, where it is the search itself. Every term has to match, upper or lower case alike:

  • Plain words and "quoted phrases" — looked for in the subject, the sender, the preview and the body. A word with a colon is a word too: Re:, Fwd:, Invoice:, Ticket:123, a link.
  • from:, to:, cc: — the header contains the text: from:boss@corp.com, to:sales.
  • subject:invoice or subject:"two words".
  • is:unread, is:read, is:starred, is:important.
  • has:attachment — a real file, not an image in the signature.
  • label: — Gmail's labels by name (label:INBOX, label:STARRED), yours by name or id (label:Invoices, label:Label_12).
  • in:inbox, in:sent, in:spam, in:trash.
  • - in front of any of them excludes it: -label:Newsletters, -from:noreply@shop.com, -"unsubscribe".
from:boss@corp.com is:unread
subject:invoice has:attachment -label:Paid
"order confirmed" in:inbox

That is the whole syntax. OR, parentheses, braces and the rest of Gmail's operators — bcc:, newer_than:, older_than:, newer:, older:, after:, before:, larger:, smaller:, size:, filename:, category:, list:, deliveredto:, rfc822msgid:, and is:, has: or in: with any other value, like is:snoozed — are not supported. The form says so under the field as you type, and saving refuses it with the same message:

"newer_than:" isn't supported in this filter. Use words, "phrases", from:, to:, cc:, subject:, is:unread, is:read, is:starred, is:important, has:attachment, label:, in:, and - to exclude.

Only those operator names count as operators, upper or lower case alike. Any other word with a colon — Re: invoice, Fwd:, Order:, https://shop.com — is searched as text, the way Gmail does it. For an OR, use two triggers — or one trigger and a Router after it.

On Deleted, a message deleted for good arrives with almost nothing left — no sender, subject or body (see What it sends) — so a filter has nothing to match it on. With a filter set, those deletions are skipped, and only messages moved to Trash are filtered: they fire when they match. Without a filter, both fire. The form says so under the field when the event is Deleted.

ℹA filter saved before October 1, 2026, when the filter only understood from:, to:, subject: and words — searching anything else as literal text, and words only in the subject, preview and body — goes one of two ways:
  • If it uses an operator that is now refused — newer_than:, OR…— it keeps working exactly as it did, and the trigger logs a warning once. Saving it again asks you to fix it.
  • Otherwise it is read with the syntax above, and it can match differently. is:unread — the example on the form — has:attachment, cc:, label:, in:, -word and "a phrase" used to be searched as literal text, so they almost never matched; now they filter as described. And plain words are now looked for in the sender too. Check what it fires on with Test push.

5. Download attachments

Off by default: the payload carries each attachment's name, type, size and Gmail id, but not the file. Tick Download attachments and every real attachment is saved to your storage before the pipeline fires, with a _file reference at attachments[i]._file — the same reference the AI Node, Vector Store and File Transform read. Images embedded in the body are never downloaded.

The files count against your plan's storage, and its per-file limit applies. A file that doesn't fit is skipped, the reason goes to _attachmentErrors, and the email fires anyway with the rest. To forward them, point the Gmail node's attachments at {{payload.attachments}}.

6. Test push

Test push reads the most recent messages this trigger would pick up — with its event, labels and filter, and skipping what delivery would skip (see No loops) — and shows them as the payload the pipeline would get. Pending changes are saved first. Nothing runs and nothing is downloaded: with Download attachments on, an example shows the _file each attachment gets.

7. Activate the watch

Click Activate. HostWebhook calls Gmail's users.watch() registering the Pub/Sub topic. From that moment every matching change fires the pipeline within seconds. Google caps watches at 7 days — HostWebhook renews them every night before they expire.

Several triggers on one Gmail account share one watch: Gmail keeps a single one per mailbox, so HostWebhook asks for what covers all of them, and each trigger keeps applying its own event, labels and filter. Deactivating one leaves the watch running while another still uses it. How many can be active at once depends on your plan — see Several triggers on one connection.

No loops

A flow that answers email sends email, and some of it lands in the mailbox the flow watches — a copy to yourself, a reply, an approval request. The trigger does not fire on it:

  • Every email sent in a run that this trigger started — by the Gmail node, by Send and wait (including what the flow sends after someone approves or rejects), or by the Email node — carries a mark with the trigger's id, and the trigger skips it even if it lands in a watched label and matches the filter. An email from another flow still fires it.
  • Your own outgoing mail — in Sent and not in Inbox — is skipped by the new-message events (New email, New attachment, New thread, New email matching search), unless you selected the Sent label: then it is what you asked for. That is where the loop lives — a reply the flow sends shows up as a new message.

New labeled email, Label removed, New starred email and Deleted do fire on mail you sent: labeling, starring or deleting a message is a change someone makes to it, not mail coming in, so there is no loop to cut. Starring a message in Sent, or moving it to the Trash, fires them like any other. The mark above still applies to them — except to a deletion for good, where nothing of the message is left to read.

What it sends

One payload per message, the same for every event. It carries the same fields the Gmail node's getMessage returns — inlineAttachments included: the images embedded in the body, kept apart from attachments so that hasAttachments means “it brings a file” — plus, with Download attachments on, a _file on each attachment:

{
  "_meta": { "iterable": false, "count": 1 },
  "id": "18f4a2c9e1b7d035",
  "threadId": "18f4a2c9e1b7d035",
  "labelIds": ["INBOX", "UNREAD", "Label_123"],
  "snippet": "Hi, attached is the invoice for September…",
  "historyId": "987654",
  "internalDate": "2026-09-30T15:30:00.000Z",
  "from": "Sam Billing <billing@example.com>",
  "to": "you@example.com",
  "subject": "Invoice #1042",
  "messageIdHeader": "<CAF…@mail.example.com>",
  "body": "<p>Hi, attached is the invoice…</p>",
  "bodyText": "Hi, attached is the invoice…",
  "bodyHtml": "<p>Hi, attached is the invoice…</p>",
  "size": 182480,
  "hasAttachments": true,
  "attachments": [
    {
      "filename": "invoice-1042.pdf",
      "mimeType": "application/pdf",
      "size": 164322,
      "attachmentId": "ANGjdJ8…",
      "inline": false,
      "_file": {
        "id": "66fb…",
        "key": "org/…/invoice-1042.pdf",
        "originalName": "invoice-1042.pdf",
        "mimeType": "application/pdf",
        "size": 164322,
        "downloadUrl": "https://files.hostwebhook.com/…"
      }
    }
  ],
  "inlineAttachments": []
}

cc, references and inReplyTo appear when the email has them; _file only with Download attachments on.

Deleted also says which kind of deletion it was. A message moved to Trash arrives whole, with "event": "deleted", "permanent": false and deletedAt — it can still be read, or taken out of the Trash with the Gmail node. A message deleted for good can no longer be read, so it arrives with what is left — and with a Query filter set it doesn't arrive at all:

{
  "_meta": { "iterable": false, "count": 1 },
  "id": "18f4a2c9e1b7d035",
  "threadId": "18f4a2c9e1b7d035",
  "historyId": "987702",
  "labelIds": ["TRASH"],
  "event": "deleted",
  "permanent": true,
  "deletedAt": "2026-09-30T16:02:11.000Z"
}

Common downstream patterns

  • Classify and route. Trigger → AI Node with the Classify task (billing / support / sales) → Router → one branch per category. Works for inboxes that receive mixed mail.
  • AI-assisted reply. Trigger → AI Node whose tools include a Gmail node in AI toolkit mode, with only draft_email ticked, and a prompt like “draft a polite reply to {{payload.from}} thanking them and suggesting next steps”. The draft waits in Gmail for you to review and send.
  • Auto-label. New email matching search with subject:invoice has:attachment → Gmail node, addLabel on {{payload.id}}. No AI, no per-event model cost.

Notion setup

Notion fires on pages added or edited — a new row in a database, or a change anywhere you shared with the connection.

1. Connect a workspace

Click Connect Notion and, on Notion's consent screen, tick the pages and databases this trigger should reach. Authorizing is not sharing: whatever you skip there stays invisible, and the pickers come back empty. You can add more later from Notion itself with ··· › Connections.

2. Say what fires it

  • Page added and Page updated — pick one or both. Nothing is ticked for you.
  • Where to watch — one database, or the whole workspace. Pick the database from the list, or paste its link or id as Notion gives it: rows live in the database's data source, which has an id of its own, and the trigger looks it up for you. One database is exact: when more rows change between two checks than Notion returns in one answer — a CSV import, forty rows moved to Done at once — the trigger reads on, page after page, and every row fires. The workspace mode is broader and less precise, because Notion's search cannot filter by date, only sort, so on a busy workspace with a long interval changes can fall off the end.
  • How often — every 5 minutes at the fastest (15 on Free), like every trigger that checks on a schedule. Notion allows about three requests per second per connection, shared with everything else you run against it.
  • On the first run — by default everything already in the database is ignored. Ask for a backfill only if you want the existing rows to fire. A database that is still empty when you activate is fine: the first rows added after that fire.
  • Ignore changes made by this flow — on by default. See No loops.
ℹA page counts as added when it was created after the previous check — even if someone kept filling it in before the next one came round. It arrives once, as added; edits made after that check arrive as updated.

No loops

A flow that writes back to Notion — sets a Status on the page that fired it, adds a row to the database it watches — makes an edit like any other, and the next check would see it. Notion shows every write HostWebhook makes under the same connection, whichever flow made it, so the author alone cannot single out this flow's own writes. Instead, Ignore changes made by this flow — on by default — skips the events caused by what a run of this same flow has just written in Notion: the page it created, the properties or content it changed. A flow that marks the page that fired it as seen does not fire itself again.

Changes made by people, and by other flows, still fire it. Two flows that write to each other's databases can still feed each other: give one of them a condition to stop on, such as a Filter on the Status it sets.

Test push, and when a check fails

Test push asks Notion for the most recent pages and runs them through this configuration, without dispatching anything. When Notion refuses — a database no longer shared with the connection, a link that is not a database, a revoked connection — it shows Notion's reason instead of a generic server error.

The same goes for the checks once the trigger is active. When one fails, the trigger's panel shows that last error until a check goes through again — a trigger that never fires says why.

What it sends

Properties arrive flattened, so a status is {{payload.properties.Status}} and not a walk through Notion's type wrappers. The untouched object is still there under raw for anything the flattening does not cover.

The page comes as id and also as pageId, the name the Notion Action suggests: {{payload.pageId}} there acts on the page that fired. What it hangs from is parentId; for a database row that is the data source, also sent as dataSourceId. A page under another page has no dataSourceId.

{
  "eventType": "page_added",
  "id": "24f8a1b2-c3d4-4e5f-8091-a2b3c4d5e6f7",
  "pageId": "24f8a1b2-c3d4-4e5f-8091-a2b3c4d5e6f7",
  "title": "Fix the Google login",
  "url": "https://www.notion.so/24f8a1b2...",
  "createdTime": "2026-08-07T12:00:00.000Z",
  "lastEditedTime": "2026-08-07T12:00:00.000Z",
  "parentId": "1a2b3c4d-5e6f-4a8b-9c0d-e1f2a3b4c5d6",
  "dataSourceId": "1a2b3c4d-5e6f-4a8b-9c0d-e1f2a3b4c5d6",
  "properties": {
    "Status": "Not started",
    "Due": "2026-08-14",
    "Owner": ["Ariel"],
    "Done": false,
    "Points": 3,
    "Tags": ["backend", "urgente"]
  },
  "raw": { "object": "page", "...": "..." }
}

Google Drive setup

Drive fires on changes inside a folder, a whole My Drive or a Shared Drive. Two steps say what counts: the event (file events, Shared Drive events, or both) and then the operations inside it.

Operations

Once you pick the event, an Operations list appears under it. Tick only what should fire — most flows want one or two:

  • Created — a new file appears in the watched scope.
  • Edited — content or metadata changed.
  • Renamed — the file's name changed.
  • Moved — the file changed folder within what is watched (moving it out of the watched folder does not fire).
  • Trashed — sent to the trash (still restorable).
  • Deleted — permanently deleted, or gone from the watched scope.
  • On a Shared Drive: Drive renamed and Drive settings changed — its settings or its members changed. Google also reports the drive when a file inside it is trashed or deleted; when nothing about the drive itself changed, that does not fire.

Every event carries the operation it was in as {{payload.operation}}, so one trigger can still branch on it downstream.

ℹGoogle only reports “this file changed” — the operation is worked out by HostWebhook. Deletions and trash are explicit. A file counts as created while its created and modified timestamps still match. Renamed and moved compare against the names and folders HostWebhook has already seen: it learns the most recent files when the trigger activates and keeps learning with every change. The first change to a file it has never seen counts as created or edited, never renamed or moved. Moving a file or trashing it fires even though its content did not change, and so does putting it back where it was. Restoring a file from the trash fires only when the trigger takes trash events (Trashed ticked, or the old include-removed switch on). Google also reports a folder when something inside it changes; a file or folder HostWebhook learned when the trigger activated and that has not changed since does not fire.

Folders inside folders

Watching a folder covers the files placed directly in it. To also catch files saved in folders nested under it — folders in folders in folders, at any depth — turn on Advanced → Include subfolders. HostWebhook walks up each file's folder chain to check it belongs to the watched folder, and remembers the folders it has seen, so a busy subfolder costs one lookup rather than one per file. A subfolder the credential cannot read counts as outside. Watching the whole drive needs none of this: everything is already in scope.

Backfill on activate

Like the Sheets trigger, Advanced → Backfill on activate emits one event for every file that is already there when you press Activate — following the rest of the configuration: the files placed directly in the watched folder; the folder and all its subfolders when Include subfolders is on; or the whole drive when no folder is chosen. The file-type filter applies; the trash never does. Each event carries "operation": "backfill" — the Operations list is not applied, since a file that was already there is neither created nor edited.

ℹTest push follows the same rule: it lists the most recently modified files in the watched folder (and its subfolders when that is on) of the chosen file type, whatever the Operations list says — they are files, not changes, and the button exists to give you real data to build templates from. Each sample still carries an operation worked out from the file's timestamps.
  • Up to 500 files, newest first (and up to 200 folders deep-walked).
  • Only on your click. The daily renewal of the Drive watch runs the same activation but never repeats the backfill.
  • A backfilled file that later changes fires again as a normal change; re-activating by hand does not re-send files that were already delivered unless they changed.
  • With Attach file content on, each backfilled file arrives with its _file — the usual way to load a folder into a vector store once before going live.

What it sends

{
  "operation": "renamed",
  "changeType": "file",
  "fileId": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
  "removed": false,
  "time": "2026-09-14T12:00:00.000Z",
  "previous": {
    "name": "Propuesta ACME v1.docx",
    "parents": ["1ZyXwVuTsRqPoNmLkJiHgFeDcBa"]
  },
  "file": {
    "id": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
    "name": "Propuesta ACME v2.docx",
    "mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    "size": 284160,
    "createdTime": "2026-09-01T09:30:00.000Z",
    "modifiedTime": "2026-09-14T12:00:00.000Z",
    "parents": ["1ZyXwVuTsRqPoNmLkJiHgFeDcBa"],
    "trashed": false,
    "url": "https://docs.google.com/document/d/1AbCdEfGhIjKlMnOpQrStUvWxYz/edit"
  }
}

A deletion arrives with removed: true and file: null — the file is gone. What it was comes in previous: { "name": "Propuesta ACME v2.docx", "parents": ["1ZyX…"] }, as long as HostWebhook had seen the file — that is also how a deletion inside a watched folder, or of a file of the filtered type, is recognised. Renamed and moved events carry previous too, with the old name and folders. Each event's id names the file and the state it reached (fileId:modifiedTime:…); since 2026-10-02 it has that longer form, so a flow that used it as a key will see new values. A Shared Drive event has no file either; it carries driveId and drive.name, and its id is drive:driveId:time (empty before 2026-10-02).


Google Sheets setup

Sheets fires on rows added, changed or deleted in a spreadsheet, and on new tabs. Google has no subscription for what is inside a sheet, so HostWebhook subscribes to the spreadsheet file through Drive: when Google says the file changed, HostWebhook reads the watched tabs, compares them with the copy it kept from the last time and fires once per change it finds.

1. Connect a Google Sheets credential

Pick a Google credential connected for Google Sheets. It also reaches the Drive file you choose in the next steps, which is how the trigger subscribes to it (see One Google credential, one product).

2. Pick what fires it

The Event Trigger comes first, and nothing is picked for you. Pick at least one — you can pick several:

  • Row added — a new row appears.
  • Row updated — a cell in an existing row changes.
  • Row deleted — a row disappears.
  • Tab added — a new tab is created, when watching all tabs. It fires once for the tab, with how many rows it has; those rows don't fire, and what is written in the tab afterwards fires as usual. Deleting a tab fires nothing.

Row updated and Row deleted need a key column to tell one row from another.

3. Spreadsheet and tabs

  • Spreadsheet — chosen with Choose from Google Drive: Google opens its own window with your spreadsheets, a search box and your shared drives, and HostWebhook gets access to the spreadsheet you pick and nothing else. The trigger can only watch a spreadsheet chosen there — that is what lets Google tell HostWebhook when it changes. With a Google service account, which can't open the window, paste the spreadsheet's URL or ID instead and share the file with the service account's email.
  • Watch mode — All tabs or Specific tabs; neither is picked for you. All tabs watches every tab of the file, the ones created later included, and Auto-watch new tabs (on by default) is what makes a new tab fire Tab added. Specific tabs watches only the ones you pick under Tabs to watch, by name: rename one in Google Sheets and it stops being watched. New tabs are ignored.

4. Header row and key column

  • Header row — the row with the column names, 1 to 100 (1 if you don't change it). Data starts on the next row. It is the same for every watched tab.
  • Key column — the column whose value is each row's own: an order number, an email, an ID. It is what tells an edited row from a new one. Pick it from the header row, or pick — Let HostWebhook detect it —. In each tab it is looked up by name.

Left to HostWebhook, it is chosen when you activate, from the first 100 rows of the tab with the most rows: a column at least half filled in, whose filled-in values are at least 80% unique, preferring names that contain id, email, uuid, key, sku or identifier, and the leftmost one on a tie. The columns that this flow's own Google Sheets nodes write in that sheet are left out. Once the trigger is active, its page shows Auto-detected: and the column it chose.

The choice keeps being checked on every notification. If the detected column's values are being rewritten — more than one in ten of the ones it had are gone, deleted rows aside — or its empty cells start being filled in on rows that were already there, it is dropped, the logs say why, and another column is detected in its place, or none. A column you picked yourself is never changed.

Without a key column — none was detected, or a tab doesn't have it — the trigger can only count rows. When a tab has more rows than before, the extra ones at the bottom fire as Row added; Row updated and Row deleted never fire. A row inserted in the middle comes out as the tab's last row.

With a key column:

  • A row whose key cell is empty still fires as added — there is one more row than before — but editing or deleting it fires nothing.
  • Changing a row's key cell looks like one row leaving and another arriving: it fires as Row deleted and Row added — or once, as Row updated, when those two and Row updated are all picked.
  • Two rows with the same key are told apart by their position.
  • Renaming or removing a column changes it in every row that has a value there: with Row updated picked, each of those rows fires — as a batch, above the threshold. Rename the key column itself and the trigger loses it: only rows added at the bottom fire until a key column is picked or detected again.

5. Watched columns

Optional, and offered once you pick a key column yourself: only a change in one of these columns counts as Row updated. Empty, any cell counts. The event still lists every column that changed in changedColumns.

6. Advanced

  • Batch threshold — 10 unless you change it, 0 to 10,000. When one notification brings more row changes in a tab than this, they arrive as one event with all of them (see What it sends). 0: every change is its own event.
  • Per-row debounce window — in seconds, 0 (off) unless you change it. With it on, Row updated waits: edits to the same row merge into one event — the row as it ends up, previousRow as it was before the first edit, and every column touched in changedColumns — and each new change restarts the wait, so they go out once the sheet has been quiet that long. Added and deleted rows, new tabs and changes that arrive as a batch don't wait. Your plan caps it: 60 seconds on Free, 120 on Starter, 300 on Pro, 600 on Enterprise; a longer value uses the cap. Pausing the trigger drops what is still waiting.
  • Backfill on activate — off unless you turn it on. With Row added picked, activating sends every row already in the watched tabs as Row added, in batches if they are over the threshold. Each activation with it on sends them all again, so turn it off once they have gone out.

7. Test push

Test push shows what the trigger would send right now, without running anything and without using anything up: the changes waiting since the last notification, if there are any — with the events, key column, watched columns and batch threshold applied — or else the last rows of each watched tab, shaped as Row added. It reads the first five watched tabs at most, and says so when there are more. It also says which header row and key column it used.

How it fires

  • Activating reads the watched tabs and keeps that copy: only what changes afterwards fires. Activating again — after a pause, say — takes a fresh copy, so what changed while the trigger was paused doesn't fire.
  • Values arrive as text, the way the sheet displays them: numbers, dates and percentages in the cell's format (a price formatted as currency arrives as "$1,250.00"), a formula as its result, an empty cell as "".
  • Google's subscription to the file lasts up to a day. HostWebhook renews it every night at 01:00 UTC, and also when a notification arrives in its last hour.
  • The watched tabs together can have up to 500 rows on Free, 2,000 on Starter, 10,000 on Pro and 100,000 on Enterprise. Over that, activating is refused, and a notification that finds more fires nothing until the sheet is back under the cap — the logs say how many rows there are.
  • Several Sheets triggers can watch the same spreadsheet — to feed two workflows, say. Each keeps its own copy and its own subscription. How many can be active on one connection depends on your plan — see Several triggers on one connection.

No loops

A flow that writes back to the sheet it watches makes a change like anyone else's, and the trigger can't tell its own from the rest. Activating doesn't refuse it, so keep the flow from feeding itself:

  • Adding rows to a watched tab, with Row added picked, fires the flow again on every run. Write them somewhere else.
  • Marking the row that fired it — a Status, a Processed column — fires Row updated once more, and on every run if the value is new each time, like {{$now}}. Pick the key column yourself and leave that column out of Watched columns, or don't pick Row updated.
  • When HostWebhook detects the key column, it leaves out the columns this flow's Google Sheets nodes write in that sheet, so marking a row doesn't make it look like a new one. A Google Sheets node in AI toolkit mode writes whatever the model decides, so its columns can't be left out.

What it sends

One event per row that changed, the same shape for Row added, Row updated and Row deleted:

{
  "_meta": { "iterable": false, "count": 1 },
  "id": "1SpReAdShEeT0123456789AbCdEf:0:12:row_updated:2026-10-06T18:04:11.233Z",
  "eventType": "row_updated",
  "spreadsheetId": "1SpReAdShEeT0123456789AbCdEf",
  "spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1SpReAdShEeT0123456789AbCdEf/edit",
  "sheetTitle": "Orders",
  "sheetGid": 0,
  "rowNumber": 12,
  "row": { "Order": 4471, "Customer": "Ana Ruiz", "Total": 1250, "Paid": true, "Status": "Shipped" },
  "rowRaw": ["4471", "Ana Ruiz", "1250", "true", "Shipped"],
  "previousRow": { "Order": 4471, "Customer": "Ana Ruiz", "Total": 1250, "Paid": true, "Status": "Paid" },
  "changedColumns": ["Status"],
  "keyColumn": "Order",
  "keyValue": "4471",
  "modifiedTime": "2026-10-06T18:04:11.233Z"
}
  • row is the row by column name, and rowRaw the same cells in order. A column with an empty header is left out of row.
  • In row and previousRow, numbers arrive as numbers and checkboxes or TRUE/FALSE as booleans — compare with > 1000, not with text. The value is the cell's, without its format: $1,250.00 arrives as 1250 and 12% as 0.12. Dates arrive as they appear in the sheet, and a code with leading zeros like 00123 stays text. rowRaw and keyValue are always text. Triggers created before October 2026 keep sending every cell as text, as it appears in the sheet, so the flows built on them don't change.
  • rowNumber is the row's number in the sheet, header rows included.
  • Row updated brings previousRow and changedColumns. Row deleted brings the row as it was, in row and in previousRow, with the number it had. On Row added both are null.
  • keyColumn and keyValue say which row it is when there is a key column — what a Google Sheets node downstream takes as match column and match value to update that same row.
  • modifiedTime is when HostWebhook found the change, not when the cell was edited.

Above the batch threshold, the changes of a tab arrive as one event, with the entries above in rows. It is marked iterable, so the next node runs once per row — a flow that reads {{payload.row.Status}} on a single event finds no row at the top of a batch:

{
  "_meta": { "iterable": true, "iterateField": "rows", "count": 14 },
  "id": "1SpReAdShEeT0123456789AbCdEf:0:batch:2026-10-06T18:04:11.233Z",
  "eventType": "batch",
  "spreadsheetId": "1SpReAdShEeT0123456789AbCdEf",
  "spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1SpReAdShEeT0123456789AbCdEf/edit",
  "sheetTitle": "Orders",
  "sheetGid": 0,
  "modifiedTime": "2026-10-06T18:04:11.233Z",
  "rowCount": 14,
  "keyColumn": "Order",
  "rows": [ { "eventType": "row_added", "rowNumber": 13, "row": { "…": "…" } } ]
}

Tab added carries no row — it says how many the new tab has:

{
  "_meta": { "iterable": false, "count": 1 },
  "id": "1SpReAdShEeT0123456789AbCdEf:1842910:tab_added:2026-10-06T18:10:02.517Z",
  "eventType": "tab_added",
  "spreadsheetId": "1SpReAdShEeT0123456789AbCdEf",
  "spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1SpReAdShEeT0123456789AbCdEf/edit",
  "sheetTitle": "November",
  "sheetGid": 1842910,
  "rowCount": 0,
  "modifiedTime": "2026-10-06T18:10:02.517Z"
}

Google Calendar setup

Calendar fires when an event in one calendar is created, changed or cancelled. Activating opens a push channel on that calendar: Google tells HostWebhook that something changed, HostWebhook asks Calendar what changed since the last time — deleted events included — and fires once per event that passes this trigger's Event Trigger and Summary filter.

1. Connect a Google Calendar credential

Pick a Google credential connected for Google Calendar.

2. Pick what fires it

One Event Trigger per trigger, and nothing is picked for you:

  • New event — an event is added to the calendar.
  • Event changed — an existing event is edited or moved.
  • Event cancelled — an event is deleted, which Google reports as cancelled.
  • Any change — all of the above.

Google doesn't say whether an event is new or changed, so HostWebhook works it out. It remembers the events this trigger has already seen (the last 2,000), and an event is new the first time the trigger sees it, if it was created after the trigger was activated (or up to a minute before). Anything else that happens to an event that isn't cancelled is a change. So an event created before you activated counts as changed the first time someone edits it, and an event created and touched up a second later — a flow that adds a Meet link to it — fires New event once and Event changed for the touch-up.

3. Calendar and summary filter

  • Calendar — the account's calendars by name, the primary one first. A shared or secondary calendar's id looks like an email (abc123@group.calendar.google.com). Left unset, the trigger watches the account's main calendar. One created or shared after the page opened shows up with the refresh button, and one picked earlier that is no longer in the list shows as (no longer in your list).
  • Summary filter — optional. Only events whose title contains this text fire, upper or lower case alike.
⚠A cancelled event can arrive with little more than its id and status: Google doesn't always send the title, the times or the guests of an event that is gone. With a Summary filter set, a cancellation that comes without its title doesn't match it, and doesn't fire. If the flow needs those details, keep a copy when the event is created or changed, under its id.

4. Test push

Test push lists the events this trigger would pick up, most recently changed first, with its Event Trigger and Summary filter applied — it looks at the last 40 events changed in the past 30 days. Nothing runs. It can't know which events the trigger has already seen, so it tells new from changed by a simpler rule: an event is new when it was last updated within 5 seconds of being created.

How it fires

  • It starts when you activate: nothing from before is sent.
  • Each change fires once. Google can repeat a notification; an event in the same state doesn't fire twice, and each new change to it does.
  • If Google no longer accepts the point the trigger had reached (it answers 410 Gone), the trigger starts again from that moment, and a warning in the logs says the changes in between were missed.
  • Google closes the channel after a while. HostWebhook renews it before that, in a check that runs every night at 01:00 UTC.
  • Each trigger opens its own channel, so several can watch the same calendar.

No loops

Nothing tells this flow's own changes apart: an event the flow creates or edits in the calendar it watches fires the trigger like any other. Write to another calendar, or stop the flow with a Filter on something it sets.

What it sends

One event per calendar event, with the same fields whatever fired it:

{
  "_meta": { "iterable": false, "count": 1 },
  "id": "7h3k2m1n4p5q6r8s9t0u",
  "htmlLink": "https://www.google.com/calendar/event?eid=N2gzazJtMW40cDVxNnI4czl0MHU",
  "status": "confirmed",
  "summary": "Proposal review",
  "description": "Go over the scope before sending it.",
  "location": "Room 3",
  "start": { "dateTime": "2026-10-08T10:00:00-06:00", "timeZone": "America/Mexico_City" },
  "end": { "dateTime": "2026-10-08T11:00:00-06:00", "timeZone": "America/Mexico_City" },
  "attendees": [
    { "email": "ana@example.com", "responseStatus": "accepted", "organizer": true },
    { "email": "sam@example.com", "responseStatus": "needsAction", "organizer": false }
  ],
  "organizer": { "email": "ana@example.com", "displayName": "Ana Ruiz" },
  "creator": { "email": "ana@example.com" },
  "recurringEventId": null,
  "recurrence": null,
  "created": "2026-10-06T17:20:04.000Z",
  "updated": "2026-10-06T17:20:04.512Z"
}
  • start and end come as Google sends them: a dateTime with its offset for a timed event, or only a date for an all-day one — so {{payload.start.dateTime}} is empty on an all-day event. HostWebhook doesn't convert them to the workspace's time zone.
  • A recurring series arrives as one event, with its rules in recurrence. A single occurrence that was changed or cancelled arrives on its own, with recurringEventId pointing at the series.
  • attendees has each guest's email, answer and whether they are the organizer; organizer and creator come as Google sends them. Missing text arrives as "", missing objects as null.
  • {{payload.id}} is what a Google Calendar node downstream takes to update or delete that event.

Slack setup

Pick a Slack credential, the events that fire the trigger (messages, mentions, reactions added or removed, channels created, members joining, files shared) and, optionally, the channels to watch. The bot only hears channels it is in: use Add bot in the channel list.

No loops

The trigger's own bot is always ignored — a flow that answers in the channel never fires itself. Ignore bot messages (on by default) decides about other bots and apps. Edits, deletions and link previews are not new messages and do not fire it.

Slash commands

With Slash commands on, the trigger answers Slack right away and runs the flow after, so a slow flow (an AI agent, say) does not show "operation_timeout". A command only reaches the triggers whose channel list includes the channel it was typed in (an empty list means every channel).

To give each command its own flow, list it under Only these commands (for example /order, /support; the slash is optional). A trigger with that list empty receives every command from its channels. If a command reaches no trigger, the Logs page says so. The command itself is created in your Slack app (Slash Commands), with the Request URL https://api.hostwebhook.com/api/triggers/slack/commands.


Discord setup

Pick a Discord credential — your own bot (a bot token) or HostWebhook's app (an OAuth connection, installed with Add to Discord) — and what fires the trigger. The events come in two families:

  • Interactions — someone aims at the bot: Slash commands and Buttons & menus. HostWebhook answers Discord at once, so the user sees Bot is thinking…, and runs the flow after. A Discord Action can then replace that line with its answer.
  • Server events — they arrive over a live connection the moment they happen: Mentions, Reaction added, Reaction removed and Direct messages. Removing a reaction and putting it back fires Reaction added again: each one is a new event. Mentioning the bot's role (Discord offers both when you type its name) also counts as a mention. The message text of a role mention only arrives if the application has Message Content Intent turned on in the Discord Developer Portal (Bot → Privileged Gateway Intents); without it the mention still fires, with empty text. Mentioning the bot itself always brings the text.

Slash commands

List them under Slash commands to register and activate: HostWebhook registers them in Discord. A command reaches only the triggers that list it — a trigger with /order does not fire on /status. With your own bot, a trigger with no commands of its own (you registered them outside HostWebhook) receives every command. With Add to Discord, a trigger only ever receives the commands it lists. Buttons and menus reach every trigger on that server that listens to them.

Discord keeps one list of commands per application and server, so it holds the commands of every active trigger there, whichever credential each one uses: activating one trigger never erases another's, and deactivating one keeps the commands another still uses. Remove every command from an active trigger and click Apply changes, and they are deleted from Discord.

ℹAutocomplete and modals are not supported. A command option marked autocomplete shows an empty list and the user types the value; the panel no longer offers Autocomplete or Modal submits.

Direct messages

Only with your own bot. A DM carries no server, so with HostWebhook's app it can't be tied to your workspace: the panel does not offer Direct messages with an OAuth connection, and activating such a trigger through the API is refused with a 400.

No loops

Messages posted by the trigger's own bot, and messages posted through any Discord webhook, never fire it. A flow that answers a mention — by bot or by webhook — does not fire itself, and posts from integrations that use webhooks (GitHub's, for one) do not fire it either.


Jira setup

Pick an Atlassian connection, what fires the trigger and, optionally, which issues. Jira calls HostWebhook the moment something happens: activating registers a webhook in Jira for you, and HostWebhook renews it before Jira's 30-day expiry. These webhooks don't appear under Jira's System › WebHooks — those are the ones made by hand — so once the trigger is active its panel shows what Jira has on file. Renew now extends that same webhook, as the nightly renewal does; it doesn't register a new one.

Test push shows real issues and comments from your site: the ones this trigger's JQL picks up in the last 30 days, with its summary filter applied, shaped as the trigger delivers them. A newly created issue arrives as jira:issue_created, an older one as jira:issue_updated. With a status transition filter only issues already in those statuses are shown; a “from” status can't be told from an issue as it is now, so with one set no issue samples are shown.

What fires it

  • Issues — jira:issue_created, jira:issue_updated, jira:issue_deleted.
  • Comments — comment_created, comment_updated, comment_deleted.

Those are the events Jira sends to the webhooks of a connected app — there are no worklog or sprint events for them, so the trigger doesn't offer Worklogs or Sprints. A trigger saved with them keeps its issue and comment events, and registers just those the next time it is applied.

Jira sends jira:issue_updated for every touch — an edited field, a new comment too. To fire only when an issue really moves, set the Status transition filter on the Advanced tab: only moves into the statuses you pick fire, and optionally only from the ones you pick. It leaves the other events alone.

Which issues — the JQL filter

The visual picker builds the filter from projects and statuses. Write custom JQL instead for more — but Jira applies only part of JQL to webhooks, and refuses the rest when you activate, with its reason (Clause summary is unsupported):

  • Fields: project, issuetype, status, assignee, reporter and issueKey.
  • Operators: =, !=, IN and NOT IN, with clauses joined by AND.
  • Not accepted: summary, priority, labels, text searches (~) and IS EMPTY, among others.
project = ACME AND issuetype = Bug AND status != Done
project IN (ACME, OPS) AND status = "In Progress"

Put names with spaces in straight double quotes. Left blank, every issue the connection can see fires it. To filter on the title, use Summary filter instead: HostWebhook checks it when the event arrives — part of the summary is enough, upper or lower case alike. An event that carries no issue is not held back by it.

No loops

HostWebhook writes to Jira as the person who connected the account, so Jira reports a flow's changes as theirs, and the trigger cannot tell them from that person's own edits by who made them. Instead, Ignore changes made by this flow — on by default — skips the events caused by what a run of this same flow has just written in Jira: the issue it created, the comment it added, the move, the assignment. A flow that comments on the issue that fired it does not fire itself again.

Changes made by people, and by other flows, still fire it. Two flows that write to each other's issues can still feed each other: narrow one of them with the JQL filter or the transition filter.

Several Jira triggers on one connection

Jira lets each person register a single webhook address per app. HostWebhook gives each connection one address and registers every trigger's webhook — with its own events and JQL — under it. When an event arrives, Jira says which of those webhooks it matched, and only those triggers fire. So any number of Jira triggers can share a connection, within your plan's limit. Applying changes replaces that trigger's webhook in Jira, deactivating deletes it, and renewal keeps them all alive.

⚠Jira triggers activated before this changed, in October 2026, have an address of their own and keep receiving on it. If yours was activated before then, deactivate it and activate it again once to move it to the connection's address — and do it before activating a second Jira trigger on that connection: Jira won't take a second address from the same person, and says so when you activate. For the same reason, connect each Atlassian account to HostWebhook once.

Reconnecting with another Atlassian account

A connection can be reconnected with a different Atlassian account — its card shows which one it uses. Its Jira triggers keep receiving through the webhooks the previous account registered until those expire, and the nightly renewal moves each one to the new account before then: it notices the webhook is no longer the connected account's and registers a new one. Renew now or Apply does it at once. If the new account has old webhooks in Jira that have all expired, they are removed so the address can be registered — unless an active trigger still uses them.

What it sends

The fields you use most come flattened at the top; Jira's own objects stay underneath — issue, comment, user (who made the change) and changelog — for anything else, custom fields included. Comment events add commentBody, commentAuthorName and commentCreated; an update lists what changed in changedFields.

{
  "webhookEvent": "jira:issue_created",
  "issueKey": "ACME-42",
  "issueUrl": "https://acme.atlassian.net/browse/ACME-42",
  "summary": "Checkout fails on Safari",
  "status": "To Do",
  "issueType": "Bug",
  "priority": "High",
  "projectKey": "ACME",
  "assigneeName": null,
  "reporterName": "Ana Ruiz",
  "labels": ["checkout"],
  "actorDisplayName": "Ana Ruiz",
  "changedFields": [],
  "issue": { "key": "ACME-42", "fields": { "...": "..." } },
  "user": { "displayName": "Ana Ruiz", "...": "..." }
}

{{payload.issueKey}} is what a Jira Action downstream takes to comment on, move or assign the issue that fired it.


Postgres setup

Fire when rows are inserted, updated or deleted in a Postgres table — on Neon, Supabase, RDS, Railway, or your own server through a tunnel. The trigger polls: on a schedule it runs one SELECT, ordered by a column you pick, and fires for every row past the last value it saw. New and updated rows need nothing but SELECT on the table; deleted rows need a small trigger in the database, which HostWebhook installs on activate (see Deleted rows). Either way it works through the connection pooler and holds no connection open between checks.

1. Connect the database

Pick a Postgres credential — the same kind the Postgres node uses — or paste a connection string right there, and pick its SSL mode — Railway and Supabase need Require, skip cert. A database on a private network works through a HostWebhook tunnel, exactly as for the node.

2. Table, what fires it, and the cursor

  • Table — database, schema and table, listed from the connection.
  • Fires on — New rows: a row counts once, when it appears. Updated rows: a row counts every time your application edits it. Deleted rows: a row counts once, when it is deleted — no cursor to pick, see Deleted rows. The answer decides which columns can be the cursor.
  • Cursor column — the column the trigger advances by. Only rows whose value is higher than the last one seen are fetched, so it must grow. For New rows: an increasing id or serial, or a created_at. For Updated rows: only a timestamp your application sets on every edit — an updated_at. An id cannot work there: it only grows when a row is inserted, so the trigger would fire on inserts and never on edits. The form does not offer it, and activation refuses it. Index the cursor if the table is big; rows where it is NULL are never seen.
  • Created column — Updated rows only, optional but recommended. An INSERT also sets updated_at, so without it a brand-new row would count as an edit the first time. Point it at created_at (the form proposes it when the table has one) and a row whose updated_at is within two seconds of its creation is skipped.
  • Advanced → WHERE filter — one plain SQL condition added to the poll query as AND (…): status = 'paid' AND total > 100. Column names only, no templates, no semicolons.
  • Advanced → Check every (30 s to 1 h, default 1 min), Rows per check (up to 500; the rest come on the next check) and Backfill on activate — how many rows already in the table to send on the first activation, newest first. 0 means only what arrives from now on. If the table is empty when you activate, the first rows that arrive are sent as usual.

3. Activate

Activate starts the schedule. On the first check the trigger sends the backfill (if any) and remembers the highest cursor value in the table; from then on only rows past that value fire. Pausing and re-activating keeps the cursor — nothing is sent twice. Rows that share the exact same cursor value (two inserts in one transaction, a bulk update with one now()) are handled: the trigger remembers which rows at that value it already sent.

Deleted rows

A deleted row leaves nothing a query can find later, so the database has to note it when it happens. On Activate with Deleted rows, HostWebhook adds to your database a trigger hostwebhook_deleted_rows on the table, which copies each deleted row into a log table hostwebhook_deleted_rows in the same schema, along with the transaction that deleted it. The trigger polls that log. Nothing is lost while HostWebhook is busy or down: the log keeps the rows until they are read, and rows older than 7 days are pruned.

  • Deleting in your application never fails because of it. If the log cannot be written, the row is still deleted and Postgres logs a warning.
  • Deletes are read once their transaction has finished — and once every transaction older than it has too. That is what keeps a long transaction that commits late from being skipped. The cost: a transaction left open for a long time (an idle session inside BEGIN) delays deliveries until it ends. Nothing is lost.
  • The log is private. It is created with row-level security and no policies, so only its owner reads it, and on Supabase the anon and authenticated roles get no access — the deleted rows never show up in the Data API.
  • Partitioned tables work: the trigger is copied to every partition, and each delete is logged under the table you watch.
  • The credential has to own the table (or be allowed to create triggers on it) and be able to create tables in the schema. If it cannot, activating says so — run the SQL below once as the table's owner, then activate again. If the table is busy (a long transaction holds a lock on it), activating waits at most 5 seconds and asks you to try again.
  • Pausing, deleting the trigger, or switching it to another table or mode stops reading, and about two minutes later removes the database trigger — unless another trigger still reads that table, from any credential or organization (each one signs in hostwebhook_deleted_rows_readers). Rows already logged and not yet read stay: resume and they arrive. Rows deleted while paused are not seen. Apply changes keeps the database trigger in place, so nothing is missed while it applies.
  • If HostWebhook can no longer reach the database to remove it, the log still never grows past 30 days: the trigger prunes it itself from time to time.
  • Advanced → WHERE filter applies to the deleted row's columns: status = 'paid' fires only for deleted paid orders. If a logged row no longer fits the table's columns (a migration changed a type), it is sent as it was logged, without the filter, instead of stopping the trigger.
  • A bulk DELETE logs one row per deleted row; they arrive up to Rows per check at a time. TRUNCATE does not fire row triggers, so it is not seen. There is no backfill: the log starts when you activate.

The setup SQL, for a table public.orders — change the schema and table names to yours (the table name also goes, in quotes, at the end of CREATE TRIGGER):

CREATE TABLE IF NOT EXISTS "public"."hostwebhook_deleted_rows" (
  id bigserial PRIMARY KEY,
  table_name text NOT NULL,
  row_data jsonb NOT NULL,
  deleted_at timestamptz NOT NULL DEFAULT now(),
  tx bigint NOT NULL DEFAULT txid_current()
);
CREATE INDEX IF NOT EXISTS "hostwebhook_deleted_rows_table_tx"
  ON "public"."hostwebhook_deleted_rows" (table_name, tx, id);
CREATE INDEX IF NOT EXISTS "hostwebhook_deleted_rows_deleted_at"
  ON "public"."hostwebhook_deleted_rows" (deleted_at);
CREATE TABLE IF NOT EXISTS "public"."hostwebhook_deleted_rows_readers" (
  reader text PRIMARY KEY,
  table_name text NOT NULL,
  seen_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE "public"."hostwebhook_deleted_rows" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."hostwebhook_deleted_rows_readers" ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON "public"."hostwebhook_deleted_rows", "public"."hostwebhook_deleted_rows_readers" FROM PUBLIC;
-- Supabase only (keeps the log out of the Data API):
REVOKE ALL ON "public"."hostwebhook_deleted_rows", "public"."hostwebhook_deleted_rows_readers" FROM anon, authenticated;

CREATE OR REPLACE FUNCTION "public"."hostwebhook_log_deleted_row"() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $hw$
BEGIN
  INSERT INTO "public"."hostwebhook_deleted_rows" (table_name, row_data)
    VALUES (COALESCE(TG_ARGV[0], TG_TABLE_NAME), to_jsonb(OLD));
  IF random() < 0.001 THEN
    BEGIN
      DELETE FROM "public"."hostwebhook_deleted_rows" WHERE deleted_at < now() - interval '30 days';
    EXCEPTION WHEN OTHERS THEN
      RAISE WARNING 'hostwebhook: deleted-rows log not pruned (%)', SQLERRM;
    END;
  END IF;
  RETURN OLD;
EXCEPTION WHEN OTHERS THEN
  RAISE WARNING 'hostwebhook: deleted row not logged (%)', SQLERRM;
  RETURN OLD;
END
$hw$;

DROP TRIGGER IF EXISTS "hostwebhook_deleted_rows" ON "public"."orders";
CREATE TRIGGER "hostwebhook_deleted_rows" AFTER DELETE ON "public"."orders"
  FOR EACH ROW EXECUTE PROCEDURE "public"."hostwebhook_log_deleted_row"('orders');

-- If the credential uses another role than the one running this:
GRANT SELECT, DELETE ON "public"."hostwebhook_deleted_rows" TO your_credential_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON "public"."hostwebhook_deleted_rows_readers" TO your_credential_role;
CREATE POLICY hostwebhook_reader ON "public"."hostwebhook_deleted_rows"
  TO your_credential_role USING (true);
CREATE POLICY hostwebhook_reader ON "public"."hostwebhook_deleted_rows_readers"
  TO your_credential_role USING (true) WITH CHECK (true);

If you installed it yourself with another role, HostWebhook cannot remove it: when the trigger stops, Monitoring says so, and you remove it the same way, as the table's owner:

DROP TRIGGER IF EXISTS "hostwebhook_deleted_rows" ON "public"."orders";
-- Once no table in the schema uses them any more:
DROP TABLE IF EXISTS "public"."hostwebhook_deleted_rows", "public"."hostwebhook_deleted_rows_readers";
DROP FUNCTION IF EXISTS "public"."hostwebhook_log_deleted_row"();

What it sends

One event per row. row is the whole row as the database returned it (timestamps as ISO strings, big integers as text); operation is inserted, updated, deleted or backfill; cursor is the value the trigger advanced to, as text. A deleted row carries the row as it was, plus deletedAt; its cursor is the transaction that deleted it, and there is no cursorColumn.

{
  "id": "public.orders:1042:9f2c1e7a0b3d4e5f",
  "operation": "inserted",
  "table": "public.orders",
  "schema": "public",
  "tableName": "orders",
  "cursorColumn": "id",
  "cursor": "1042",
  "row": {
    "id": 1042,
    "customer_email": "ana@acme.com",
    "total": "149.90",
    "status": "pending",
    "created_at": "2026-09-14T18:41:07.512Z"
  }
}
ℹTest push reads the most recent rows by the cursor column (with Deleted rows, the most recent entries in the log), with the WHERE filter applied, and shows them exactly as the trigger dispatches them — without moving the cursor. Before the first activation of a Deleted rows trigger the log does not exist yet, so there is nothing to show.

Airtable setup

Fire when records are created, updated or deleted in an Airtable base. Activating registers a webhook in Airtable for that base, so changes arrive within a second or two — from the Airtable interface, the API, an automation or a HostWebhook node alike. Airtable keeps the webhook for 7 days; HostWebhook renews it before it expires.

  • Credential — an Airtable connection (OAuth). When you connect it, Airtable asks which bases to grant: only those appear here. To add one later, reconnect the credential and tick it.
  • Base — required. Table — optional: empty watches every table of the base.
  • Events — any of created, updated and deleted. A batch write arrives as one event per record.

Each record arrives as its own event. Fields come by name, as the table shows them. source says where the change was made (client for the Airtable interface, publicApi for the API) and actor who made it. An updated event also carries changedFields and the values they had before, in previousFields:

{
  "operation": "updated",
  "base": { "id": "appk3g3f6Q6QtqtzA" },
  "table": { "id": "tbllW1VJOc3NRRwSM", "name": "Table 1" },
  "source": "publicApi",
  "actor": { "id": "usrw6tvBeNp5IyRpb", "email": "ana@acme.com" },
  "occurredAt": "2026-10-04T06:09:14.298Z",
  "record": {
    "id": "recXtpUtEkCYZBkv9",
    "url": "https://airtable.com/appk3g3f6Q6QtqtzA/tbllW1VJOc3NRRwSM/recXtpUtEkCYZBkv9",
    "fields": { "Name": "Order 1042", "Notes": "v2" }
  },
  "changedFields": ["Notes"],
  "previousFields": { "Notes": "v1" }
}
ℹA deleted event carries only the record's id and url: Airtable does not send the fields of a record that is gone. If the flow needs them, keep a copy when the record is created or updated. AI fields (like an «Attachment Summary») come as Airtable sends them — an object with state and value.

Plan tier

Every plan — Free, Starter, Pro and Enterprise — can create as many triggers as it needs, and keep as many active on the same connection as it wants. What the plan changes is how often a trigger that checks on a schedule may look (every 15 minutes on Free, every 5 on the paid plans) and the events a week.

Several triggers on one connection

Duplicating a trigger to feed a second workflow works on every service, on the same connection. On Free only one of them can be active per connection, on Starter three; from Pro up, all of them.

Telegram, Discord and Jira share something upstream, and all three handle it for you:

  • Telegram allows one webhook per bot. It points at the trigger activated last, asks Telegram for the updates every trigger on that bot listens to, and each update reaches all of them — each still applies its own update types and chat type. Deactivating one hands the webhook to another; only the last one removes it. Telegram's system notices — someone joined or left, a message was pinned, the group was renamed — are not Messages: pick Group events to receive them, and payload.groupEvent says which one it was (new_chat_members, left_chat_member, pinned_message…). Use one bot per organization (and per tool): a bot delivers to a single place, so connecting it somewhere else takes its updates away from here. HostWebhook warns when activating a trigger moves a bot's updates away from another place, and checks every hour — if a bot stops delivering here, the trigger shows it and its logs say what to do. If nobody on your team moved it, revoke the token in @BotFather (/revoke) and paste the new one into the credential.
  • Discord slash commands are one catalogue per application and server. Activating registers the commands of every active trigger on that application and server, whichever credential each one uses, and deactivating one keeps the commands another still uses. Each slash command reaches only the triggers that list it; buttons and menus reach every trigger listening to them (see Discord slash commands).
  • Jira takes one webhook address per person and app. Every trigger on a connection registers its own webhook — its own events and JQL — under that connection's single address, and each event reaches only the triggers whose webhook it matched (see Several Jira triggers on one connection).

Gmail limits to know

  • Every email that fires the trigger counts against your plan's weekly event limit.
  • Gmail's API quota (250 units per user per second) is shared with every other app using the account. A very busy inbox can make Google slow the reads down.
  • Watches last 7 days. Renewal runs at 01:00 UTC every day; only if it failed seven days in a row would the watch expire.
  • One Gmail watch per mailbox, shared by every trigger on that account — see Activate the watch. How many of them can be active is the plan's per-connection limit.

Self-hosted: server env

If you run HostWebhook on your own Railway, these env vars must be set on the API service for Gmail triggers to activate:

GMAIL_PUBSUB_TOPIC=projects/<your-project>/topics/<topic-name>
GMAIL_PUSH_AUDIENCE=https://api.yourdomain.com/api/triggers/gmail/push
GMAIL_PUSH_SERVICE_ACCOUNT=pubsub-gmail-pusher@<your-project>.iam.gserviceaccount.com

Plus in Google Cloud Console: (1) create a Pub/Sub topic, (2) grant pubsub.publisher to gmail-api-push@system.gserviceaccount.com, and (3) create a push subscription pointing at the API URL with a service account for JWT signing.

The Sheets and Calendar triggers need an address for Google's notifications and a shared secret each. Without them, activating fails, and a notification is refused:

SHEETS_PUSH_ADDRESS=https://api.yourdomain.com/api/triggers/sheets/push
DRIVE_PUSH_TOKEN=<long random secret>      # the Sheets notifications are checked against it
CALENDAR_PUSH_ADDRESS=https://api.yourdomain.com/api/triggers/calendar/push
CALENDAR_PUSH_TOKEN=<long random secret>