# WP Webhooks > Say what you want, get a working WordPress automation. Webhook Actions by Flow Systems is a free open-source plugin with a built-in AI agent — **Build with AI**. Describe the outcome in one plain sentence ("When someone submits my contact form, add them to my CRM"); the agent plans the integration, builds it, test-fires it against the site's real data, and hands over the switch. Underneath sits a senior-grade delivery engine that turns any WordPress `do_action` — including future ones — into a first-class REST API event: captured, mapped into a JSON payload, and dispatched asynchronously with queue, retry, replay, idempotency headers, and a full delivery log. Compatible with n8n, Make, Zapier, HubSpot, Pipedrive, Notion, Airtable, Slack, and any HTTPS endpoint. The same toolset is exposed to external AI tools via the WordPress Abilities API and the MCP Adapter, and scoped REST API tokens — including a write-capable `agent` scope that never exposes stored secrets — let AI agents (Claude Code, Cursor) and CI/CD pipelines drive the queue from the outside. A Credentials Vault stores auth secrets encrypted at rest (AES-256-GCM) so webhooks reference credentials by name instead of pasting raw Authorization headers. ## What This Site Covers WordPress already broadcasts everything important as a `do_action` — orders placed, forms submitted, posts published, users registered, subscriptions renewed. This site documents how to treat that action surface as your integration surface: any `do_action` on the site, including ones added by a plugin update next month, can be picked up as a trigger, mapped into a JSON payload, and dispatched to any HTTPS endpoint. Since version 2.0, you don't have to wire that up by hand — an in-admin AI agent builds the integration from a plain-language description, and everything it does can still be clicked together manually, scripted over the REST API, or driven from an external coding agent via MCP. The vehicle is the free open-source **Webhook Actions** plugin by Flow Systems, plus a paid **Webhook Actions Pro** extension. Since free 3.0.0 / Pro 1.9.0, Pro adds exactly two things — the two that run on our infrastructure rather than yours: hosted AI credits for Build with AI (no key required) and External Cron, a managed heartbeat that drains the delivery queue on a fixed schedule. Everything else, Code Glue snippets and dynamic URL templates included, is in the free plugin. ## Build with AI: Describe It, Watch It Build, Flip the Switch The headline feature since 2.0: an in-admin AI agent that builds webhook integrations and automations from one plain sentence. Three stages: 1. **Describe** — one plain sentence, no trigger names, no field paths, no documentation detour. "When an order is completed, create a deal in HubSpot." The agent figures out the WordPress side. 2. **It builds — and proves it works** — the agent proposes an editable plan you can review, creates the webhook disabled, captures a real payload from your site, maps the fields, probes your endpoint with a guarded test call, and fires a test delivery. 3. **You decide** — nothing goes live without you. Review what was built and flip the switch; going live, deleting, or editing a live webhook always requires explicit confirmation. One-click undo reverts the last build. Follow-ups are one line: "Actually, only orders over $50." "Rename that field before it sends." And the full manual editor is always right there. **Model access (LLM transport auto-detection):** the first prompt on a new site gets 55 free AI credits — about two complete automations — granted server-side with no key and no signup, one grant per site, never refilled. Pro plans then include a monthly pool of hosted AI credits that run on our servers, so no key is required and there is no per-token bill. Either way the builder is also free to run on your own model, indefinitely: if the WordPress 7.0 AI Client has a provider configured (Settings → Connectors), the builder uses it with no extra setup and stores no keys, or connect your own Anthropic, OpenAI, or Google API key — stored encrypted in the Credentials Vault — with a curated model picker. A free Google AI Studio key gives you Gemini at no cost and takes about two minutes to get. If a provider is rate-limited mid-build, the agent automatically falls back to another connected provider and keeps going. **Safety model:** provider API keys are encrypted in the Credentials Vault and never returned over the API. Stored webhook credentials are never sent to the AI model, and captured payload values whose field names look sensitive (passwords, tokens, keys) are redacted before any prompt is built. The model only proposes the plan — the changes run locally in the plugin, new webhooks start disabled, and every agent action lands in the Activity History audit log. **Bring your own agent:** the plugin's operations (list/create/update webhooks, set mapping and conditions, test dispatch, probe endpoint, manage credentials, build chains) are registered as `flowsystems-webhook-actions/*` abilities via the WordPress Abilities API, so external AI tools — Claude Code, Cursor, anything MCP-capable — can discover and invoke the same toolset via REST and the MCP Adapter, without ever touching WordPress admin credentials. ## The Core Idea: do_action As the Integration Surface Webhook Actions treats every WordPress hook as a potential API event. That means: - **No vendor SDK** — you don't install a per-service integration plugin for HubSpot, Pipedrive, Notion, Airtable, n8n, Make, Zapier, Slack, or any internal API. The hook itself is the API. - **No glue code rotting in `functions.php`** — the queue, retries, idempotent headers, scoped REST tokens, full delivery log, and replay button live inside the plugin. - **Future-proof** — a `do_action` shipped by a plugin update next month is automatically eligible as a trigger. No code changes required. The AI is optional. Everything the agent does you can click together by hand, script over the token-scoped REST API, or drive from an external agent via MCP — same engine either way. ## What Changes On Day One **Two-week projects become a sentence.** The wiring job that used to mean quotes, tickets, and a specialist is now something you type. Review the plan, flip the switch. "Can n8n pick this up?" gets a yes before the meeting ends. **Builds come out engineered, not glued.** Queued, retried, logged, replayable — every automation ships on the same infrastructure whether the AI built it or you did: v4 UUID per event, ISO-8601 UTC timestamps, full request/response log, `X-Event-Id` and `X-Webhook-Id` headers for clean dedup and idempotency downstream, conditional dispatch, custom headers, query parameters, and all five HTTP methods (GET, POST, PUT, PATCH, DELETE). **Describe once, adjust in one line.** The agent edits what it built — narrow a condition, rename a field, retarget an endpoint — or open the manual editor and fine-tune by hand. **Speak n8n, Make, Zapier, and AI-agent fluent.** Send any WordPress event into an automation graph in one click. Pull a Claude Code or Cursor agent into wp-admin via MCP or scoped REST API tokens and let it build integrations, inspect logs, retry deliveries, or toggle integrations during deploys — without ever touching WordPress admin credentials. CI/CD pipelines and external dashboards use the same surface. **No per-task subscription.** The queue lives in your own database; triggers and destinations you'd otherwise rent per-run from Zapier or Make run as code you own, and the bill stops scaling with event volume. ## How a do_action Becomes an API Call The plugin sits between WordPress and external endpoints. Four stages: 1. **Capture** — any registered `do_action` fires; its arguments are normalized into a clean JSON shape, with PHP objects walked into plain arrays so they actually JSON-encode cleanly 2. **Map** — dot-notation field mapping, conditions, type casting, and per-webhook custom headers and URL parameters. Conditions can evaluate before or after the mapping runs 3. **Queue** — the event lands in a persistent database queue in under 5ms and is drained by background workers: Action Scheduler (the same job runner WooCommerce uses) when available, WP-Cron otherwise, with the option to point a real system cron at the queue — or External Cron (Pro), a managed external pinger 4. **Deliver** — POST, PUT, PATCH, DELETE, or GET against any HTTPS endpoint, with exponential backoff, a replay button on every event, full attempt history, and a token-authenticated REST API so external dashboards, CI/CD pipelines, and AI agents can drive the queue from the outside ## Which WordPress Hooks Are Supported The plugin supports **any WordPress `do_action()` hook** — not just a predefined list. Hook discovery works in two layers merged together: 1. **Runtime scan** — reads `$wp_filter` (the live hook registry) in the current request context, capturing every hook that has an active callback at that moment 2. **Static file scan** — walks the PHP files of every active plugin and theme using a regex, extracting all string-literal `do_action()` calls. This catches hooks from plugins like WooCommerce that register conditionally and are absent from `$wp_filter` during a REST API request Results from both sources are merged, deduplicated, and cached for 24 hours (cache busted on plugin activation/deactivation and theme switches). **What is not captured:** dynamic hook names like `do_action("save_post_{$post->post_type}")` cannot be statically known and are not included in discovery. Users can type any custom hook name manually in the UI. **Exclusions:** a small set of hooks that are technically `do_action()` calls but meaningless as webhook triggers are filtered out of the UI. These are display-only exclusions — they do not affect runtime interception. Any hook name typed manually will still be intercepted. In practice: if a plugin or theme fires a `do_action()` with a string literal name, Webhook Actions can trigger a webhook on it — including hooks added by future plugins. ## Real Builds: One Sentence In, a Shipped Pipeline Out Not theoretical examples — documented builds you can replay on your own site, prompt included: - **Contact Form 7 → n8n.** "When a Contact Form 7 form is submitted, send it as JSON to my n8n webhook." Queued before the reply renders, delivered with automatic retry on failure. - **WooCommerce → HubSpot deal lifecycle.** "Create a HubSpot deal when an order is placed — and move it to Closed Won when it completes." Two webhooks, zero SDK, deal stages that follow the order on their own — the remote id rides the URL (dynamic URL templates). - **Built by Claude Code over REST.** "Create a webhook for completed orders and send them to n8n." — typed into Claude Code, not the admin. The assistant provisions everything over the REST API, catches its own field-path mistake, and fixes it live. - **Gravity Forms → n8n.** "Send Gravity Forms entries to n8n with just the fields I need." Field mapping, automatic retries, and a full log of every attempt. - **Multi-step automations via Webhook Chains.** A webhook completing successfully (2xx) triggers downstream webhooks, each receiving the upstream response, sent payload, and pre-mapping original payload as its starting `args[0]`. The WooCommerce → HubSpot deal → HubSpot line items → contact-to-deal-association sequence runs as one chain — every hop is a real webhook with its own log, retry, conditions, and replay. - **Subscription renewal → Slack / internal API.** Pipe operational events to the people and services that need them, with `X-Event-Id` carrying idempotency through. ## The Plugin: Webhook Actions (Free) Free, open-source WordPress plugin (GPL). Covers the entire delivery pipeline: - **Build with AI** — in-admin AI agent that plans, builds, and test-fires integrations from a plain-language description. Plan-first with hybrid confirmation: new webhooks are created disabled; going live, deleting, or editing a live webhook requires explicit confirmation; one-click undo of the last build. Uses the WordPress 7.0 AI Client when a provider is configured, or your own Anthropic/OpenAI/Google key stored encrypted in the Credentials Vault (a free Google AI Studio Gemini key works), with automatic cross-provider fallback on rate limits - **Any `do_action` as a trigger** — runtime + static discovery, plus manual entry for dynamic hook names - **Async queue delivery** — Action Scheduler-based, with WP-Cron fallback and system-cron support - **Automatic retry** — 5 attempts by default with exponential backoff (1 min → 2 min → 4 min → 8 min → 16 min, capped at 1 hour) - **Full delivery logging** — every attempt recorded with HTTP status, response body, attempt number, and timestamp - **Event replay** — re-send any past event (successful or failed) without re-triggering the original WordPress action - **Payload mapping** — dot-notation field selection, key renaming, type casting - **Code Glue snippets** — reusable PHP snippets bound to a webhook + trigger, running on the mapped payload. Reshape, enrich or rebuild an outgoing payload, or run post-dispatch logic against both payload and response (e.g. storing an external id back into WordPress). Ships with a library of prebuilt snippets for common integration patterns. Free since 3.0.0 - **Dynamic URL templates** — `{{ field.path }}` syntax injects payload values into the webhook URL, so one webhook can PATCH a different remote record per event. Free since 3.0.0 - **Per-webhook retry control** — override the global retry strategy per webhook: attempt limit, backoff strategy (exponential, fixed, linear) and base delay, with a delay preview. Free since 3.0.0 - **Conditional dispatch** — unlimited rule-based filters with nested AND/OR groups and live payload preview, so only the events that matter leave the site. No cap on the free plugin since 3.0.0 - **Idempotency headers** — `X-Event-Id` (v4 UUID per event) and `X-Webhook-Id` on every request - **All five HTTP methods** — GET, POST, PUT, PATCH, DELETE - **Custom headers and query params** — match any external API contract - **Per-webhook synchronous mode** — for testing and edge cases that require inline dispatch - **Webhook Chains** — compose multi-step automations: a webhook's 2xx response fires one or more downstream webhooks, each receiving the upstream response/payload/original as `args[0]`. Save-time cycle detection across all chains; chain links auto-cleanup when their source or target webhook is deleted; empty chains auto-delete when the last link is removed. Logs and Queue can be filtered by chain. - **Activity History** — persistent audit log of every admin, API-token, and AI-agent action: actor identity (session user or token name), structured old → new change diff, IP address, and timestamp. Filterable by action type, object type, actor, and date range. When an AI agent sends `X-FSWA-Prompt` and `X-FSWA-Reason` headers, both are captured in the log and surfaced in the Activity view - **API Tokens** — scoped access tokens accepted via `X-FSWA-Token`, `Authorization: Bearer`, or `?api_token=`. Four scopes: `read` (GET only), `operational` (read + toggle/retry/replay), `full` (operational + CRUD + reveals stored auth secrets), `agent` (same write access as `full` but can never reveal a webhook's `auth_header` or any Credentials Vault secret — designed for AI assistants). Token management always requires a WordPress admin session - **Credentials Vault** — store reusable auth secrets (Bearer token, Basic auth, API key, custom Authorization) encrypted at rest with AES-256-GCM. Secrets are write-only over the API: never returned to any caller, only a masked hint (e.g. `Bearer ****1234`). Webhooks reference a credential by `auth_credential_id` instead of pasting raw headers. By default the key is stored in the database (zero-config); define `FSWA_SECRET_KEY` in `wp-config.php` to move it out, with one-click in-app re-encryption migration - **REST API** — token-authenticated with scoped permissions, so AI agents and CI/CD can drive the queue from the outside - **WordPress Abilities API + MCP** — plugin operations registered as `flowsystems-webhook-actions/*` abilities, discoverable and invocable by external AI tools via REST and the MCP Adapter - **Guarded endpoint probe** — a safe test call to a target API to validate a credential or endpoint before wiring a webhook, with an SSRF guard, rate limiting, response-size cap, and full secret redaction - **Fully translatable (i18n)** — the entire admin interface (every screen, dialog, inline message) and all server-side strings are wrapped for translation. English is the default (source) language; Polish (`pl_PL`), Simplified Chinese (`zh_CN`), and Dutch (`nl_NL`) ship bundled and load automatically by site/user locale. Compatible with WPML and Polylang String Translation; free locales can be contributed via translate.wordpress.org. Add a language by dropping a locale `.po`/`.mo`/`.json` set into `languages/` - **WooCommerce-aware** — order status changes, payment events, and other WooCommerce actions work out of the box Compatible with: n8n, Make (formerly Integromat), Zapier, Pipedream, HubSpot, Pipedrive, Notion, Airtable, Slack, and any system that receives HTTP requests. ## Webhook Actions Pro (Paid Extension) A paid extension that adds the two things that run on Flow Systems' infrastructure rather than the site's own: hosted AI credits and a managed queue heartbeat. **It is not a feature tier.** Since free 3.0.0 / Pro 1.9.0 the delivery pipeline is entirely free — Code Glue snippets, dynamic URL templates, per-webhook retry and backoff control, unlimited AND/OR conditions and publishing a build all moved into the free plugin and are no longer gated. All Pro plans unlock the same set; they differ only by number of production sites and the size of the monthly credit pool. **Pro features:** - **Hosted AI credits for Build with AI** — every Pro plan includes a monthly pool of AI credits (Starter 5,000, Business 12,500, Agency 30,000 credits/mo) that run on our servers with our provider keys, so no API key is required and there is no per-token bill. A Build-with-AI request costs ~11 credits and a full automation takes two to three requests, so about 25 credits per build — 5,000/mo is 200+ automations. Bringing your own key stays free on every plan, including the free plugin. - **External Cron** — replace unreliable visitor-triggered WP-Cron with a fully managed external pinger (powered by Uptime Kuma at externalcron.com). On Pro license activation, a monitor is provisioned automatically and starts pinging on a fixed schedule — no server crontab, no external dashboard. Two modes: _plugin queue endpoint_ (`/wp-json/fswa/v1/cron/process?token=…`, min 20 s interval, configurable batch size 1–100) or _WP-Cron endpoint_ (`/wp-cron.php?doing_wp_cron`, min 60 s, runs the full WordPress scheduler). Switching to WP-Cron mode automatically writes `DISABLE_WP_CRON` to `wp-config.php`. Live heartbeat chart with 24 h uptime and average response time in wp-admin; last-ping error surfaced inline. Monitor URL updates automatically on cron-token regeneration or mode change. Pauses on license deactivation, resumes on reactivation - **Priority email support** and updates for the license period **Plans:** Starter from $29/month or $249/year (1 production site). Business and Agency plans add more sites and larger AI credit pools. All plans unlock the same Pro feature set. A "site" is counted per bare domain: `www.` and the environment prefixes (staging, dev, test, qa, uat, sandbox, preview and similar) fold onto the domain below them, so a production site plus its staging and dev copies is one seat. Local installs are not counted at all. **Pricing page:** https://wpwebhooks.org/pricing/ The free plugin remains fully functional without a Pro license — no features are locked or disabled. ## REST API Reference (key endpoints) **Webhook management:** - `GET /wp-json/fswa/v1/webhooks` - `POST /wp-json/fswa/v1/webhooks` - `PATCH /wp-json/fswa/v1/webhooks/{id}` - `POST /wp-json/fswa/v1/webhooks/{id}/toggle` - `DELETE /wp-json/fswa/v1/webhooks/{id}` **Delivery log inspection and retry:** - `GET /wp-json/fswa/v1/logs` (supports `chain_id` filter — narrows to deliveries triggered by any link in a given chain) - `GET /wp-json/fswa/v1/logs/stats?days=7` - `POST /wp-json/fswa/v1/logs/{id}/retry` - `POST /wp-json/fswa/v1/logs/bulk-retry` - `POST /wp-json/fswa/v1/logs/{id}/replay` **Webhook Chains:** - `GET /wp-json/fswa/v1/chains` — list chains with their links and member webhook IDs - `POST /wp-json/fswa/v1/chains` — create a chain (name, optional description) - `PATCH /wp-json/fswa/v1/chains/{id}` — rename - `DELETE /wp-json/fswa/v1/chains/{id}` — delete chain and its links - `POST /wp-json/fswa/v1/chains/{id}/links` — add a source→target edge (rejected with HTTP 409 if it would create a cycle across any chain) - `DELETE /wp-json/fswa/v1/chains/{id}/links/{linkId}` — remove a single edge; auto-deletes the chain if it was the last link **External Cron queue processing (pinged by External Cron worker):** - `GET /wp-json/fswa/v1/cron/process?token=` — processes the webhook delivery queue (authenticated via dedicated cron secret, not an API token) **Activity History:** - `GET /wp-json/fswa/v1/activity` (requires `read` scope) — filters: `action`, `action_prefix`, `user_id`, `object_type`, `object_id`, `date_from`, `date_to` - `GET /wp-json/fswa/v1/activity/{id}` - `DELETE /wp-json/fswa/v1/activity?older_than_days=N` (requires `full` scope) **Credentials Vault:** - `GET /wp-json/fswa/v1/credentials` — list credentials with masked hints (requires `full`/`agent` scope) - `POST /wp-json/fswa/v1/credentials` — create credential - `PATCH /wp-json/fswa/v1/credentials/{id}` — update credential - `DELETE /wp-json/fswa/v1/credentials/{id}` — delete (blocked with 409 if in use unless `force=true`) - `GET /wp-json/fswa/v1/credentials/key-status` — check whether a `FSWA_SECRET_KEY` constant is active - `POST /wp-json/fswa/v1/credentials/reencrypt` — migrate all secrets to a newly added constant key (requires `full` scope) **Abilities / MCP:** the same operations (list/create/update webhooks, set mapping and conditions, test dispatch, probe endpoint, manage credentials, build chains) are also registered as `flowsystems-webhook-actions/*` abilities via the WordPress Abilities API, discoverable and invocable through the MCP Adapter. **Authentication:** `X-FSWA-Token` header (or `Authorization: Bearer` / `?api_token=`) with scoped permissions. Scopes: `read`, `operational`, `full`, `agent`. The `agent` scope grants full write access but can never reveal stored secrets — safe for AI assistants. ## Headers Sent on Every Delivery - `X-Event-Id` — v4 UUID per event, for downstream dedup/idempotency - `X-Webhook-Id` — identifies the webhook configuration that produced the event - `X-Event-Timestamp` — ISO-8601 UTC - `Content-Type: application/json` ## Retry Logic Details - Distinguishes retryable failures (5xx errors, 429 rate limits, timeouts) from permanent failures (4xx and 3xx responses) - 4xx and 3xx responses are not retried — a rejected payload indicates a structural problem, not a transient one - After all attempts exhausted: event moves to `permanently_failed` status and triggers an alert - Stuck detection: a separate cron job queries for events stuck in `processing` status and resets them to `pending` - Per-webhook overrides for attempt count, backoff strategy, and base delay (free since 3.0.0) ## Key Technical Definitions **do_action**: WordPress's core hook-firing function — the integration surface this plugin exposes. **Build with AI**: the in-admin AI agent (since 2.0) that plans, builds, and test-fires webhook integrations from a plain-language description. Plan-first with hybrid confirmation — new webhooks are created disabled, going live requires explicit approval, and the last build can be undone in one click. **LLM transport**: how Build with AI reaches a model — the WordPress 7.0 AI Client (`wp_ai_client_prompt()`) when a provider is configured (no API key stored), otherwise the user's own Anthropic, OpenAI, or Google key stored encrypted in the Credentials Vault, with automatic cross-provider fallback on rate limits. **WordPress Abilities API / MCP Adapter**: the plugin registers its operations as `flowsystems-webhook-actions/*` abilities, so external AI tools (Claude Code, Cursor, any MCP client) can discover and invoke the same toolset the in-admin agent uses — via REST and the MCP Adapter. **Guarded endpoint probe**: a safe test call to a target API used to validate a credential or endpoint before wiring a webhook — SSRF-guarded, rate-limited, response-size-capped, with full secret redaction. **Action Scheduler**: the same background job runner WooCommerce uses; Webhook Actions processes its queue through it, falling back to WP-Cron when Action Scheduler isn't installed. **WP-Cron**: pseudo-scheduler triggered by site traffic, not real time. **Dead-letter state**: storage for permanently failed events (`permanently_failed` status). **Exponential backoff**: retry delay increases after each failure. **Idempotency key**: `X-Event-Id` UUID that prevents duplicate processing across retries. **Event replay**: re-sends stored payload without re-triggering the WordPress action. **Dynamic URL template**: `{{ field.path }}` syntax resolved at dispatch time, so a single webhook can target a different remote record per event. **Code Glue snippet**: PHP snippet bound to a webhook + trigger, executed pre- or post-dispatch. **External Cron (Pro)**: a managed external monitor (Uptime Kuma) provisioned automatically on Pro license activation. Pings the plugin queue endpoint or wp-cron.php on a fixed schedule independent of site traffic, replacing the unreliable visitor-triggered WP-Cron. Controlled entirely from wp-admin — no server crontab or external dashboard required. **Activity History**: persistent audit log recording every create/update/delete/toggle action performed through wp-admin or via API token. Captures actor identity, structured old → new diff, IP address, timestamp, and — for AI-agent actions — the `X-FSWA-Prompt` and `X-FSWA-Reason` headers as `_prompt` and `_reason` in the context JSON. **API token scopes**: `read` (GET endpoints only) · `operational` (read + toggle webhooks, retry/replay logs, execute queue jobs) · `full` (operational + CRUD + reveals stored auth secrets) · `agent` (same write access as `full` but can never reveal a webhook's `auth_header` or any Credentials Vault secret — intended for AI assistants such as Claude Code and Cursor). **Credentials Vault**: encrypted-at-rest secret store (AES-256-GCM). Supports `bearer`, `basic` (username + password), `api_key`, and `custom` credential types. Secrets are write-only over the REST API — only a masked hint is ever returned. Webhooks reference a vault entry via `auth_credential_id`; the decrypted value is injected at dispatch time and redacted in delivery logs. An `agent`-scope token can assign credentials to webhooks without ever reading the underlying secret. Since 2.0 it also stores the user's LLM provider API keys for Build with AI. **Internationalization (i18n)**: the entire admin interface and all server-side strings are translatable via WordPress gettext. English is the default (source) language; Polish (`pl_PL`), Simplified Chinese (`zh_CN`), and Dutch (`nl_NL`) ship bundled; the plugin is compatible with WPML and Polylang String Translation, and free locales can be contributed through translate.wordpress.org. The `.pot` template covers all PHP and JavaScript strings (Vue SPA strings are extracted from source because `wp i18n make-pot` cannot read `.vue` files). Add a language by dropping a locale `.po`/`.mo`/`.json` set into the plugin's `languages/` directory. --- # Full Page Contents --- title: "WordPress Webhooks: Say What You Want. Get It Working." description: "Free WordPress webhooks plugin: turn any do_action into a webhook integration — queued, retried, logged. Describe it and the built-in AI agent builds it." url: "https://wpwebhooks.org/" --- # Let’s build something. Describe the integration in plain words. The agent plans it, then builds and tests it for you in your WordPress admin panel. ![A vast synthetic face of pale ceramic inlaid with glowing green circuit tracery, growing out of a mass of thick black cabling in a dark server hall, its lit eyes fixed on the viewer.](https://wpwebhooks.org/pricing/ai-hero-1920.webp) / Build with AI ## It has already seen the payload and read the API. A real example of the event — yours, or one of ours for an event your site has never fired — and the destination's current API reference, read before a single field is mapped. - Payload Library 574 hook payloads from WordPress core, WooCommerce and the major form plugins - API Docs Library 312 APIs — HubSpot, OpenAI, Slack, Stripe — read on our server, not remembered - Free credits 55 on every install. No key, no signup, no card. [Every API it reads →](https://wpwebhooks.org/api-library/) next\_step From install to your first working automation in about two minutes — free AI credits included, no key to set up. [Install Free Plugin →](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the live preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) /Real builds ## One sentence in. A shipped pipeline out. Not demos of what could work — documented builds you can replay on your own site, prompt included. #cf7#n8n ### Contact form → n8n “When a Contact Form 7 form is submitted, send it as JSON to my n8n webhook.” Queued before the reply renders, delivered with automatic retry on failure. [Read the build →](https://wpwebhooks.org/examples/cf7-to-webhook/) #woocommerce#hubspot ### WooCommerce → HubSpot deal lifecycle “Create a HubSpot deal when an order is placed — and move it to Closed Won when it completes.” Two webhooks, zero SDK, deal stages that follow the order on their own. [Read the build →](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) #woocommerce#ai-agents ### Built by Claude Code over REST “Create a webhook for completed orders and send them to n8n. — typed into Claude Code, not the admin.” The assistant provisions everything over the REST API, catches its own field-path mistake, and fixes it live. [Read the build →](https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/) #gravityforms#n8n ### Gravity Forms → n8n “Send Gravity Forms entries to n8n with just the fields I need.” Field mapping, automatic retries, and a full log of every attempt. [Read the build →](https://wpwebhooks.org/examples/gravity-forms-webhooks/) /What changes for you ## You just became the integrations department. **01** / SHIP BEFORE COFFEE ### Two-week projects become a sentence. The wiring job that used to mean quotes, tickets, and a specialist is now something you type. Review the plan, flip the switch, take the credit. **02** / SOUND SENIOR ### Your builds come out engineered, not glued. Queued, retried, logged, replayable — every automation ships on infrastructure a ten-year integrations engineer would sign off on. Nobody has to know it took you a minute. **03** / DELEGATE THE BORING ### Describe once. Adjust in one line. "Actually, only orders over $50." "Rename that field before it sends." The agent edits what it built — or open the manual editor and fine-tune by hand when you feel like it. **04** / BRING YOUR OWN AGENT ### Claude Code and Cursor can run it for you. The same toolset is exposed over MCP and a token-scoped REST API, so your coding agent can build, inspect logs, and retry deliveries — without ever touching admin credentials. your\_move The senior engineer is included. You bring the sentence. [Install Free Plugin →](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) /The engine underneath ## The toolkit a senior engineer would insist on. The AI already uses it. Queue, retry, logs, replay, credentials vault, conditions — the agent builds on these instead of generating fragile glue code. And every one of them is still there when you want manual control. **01** / QUEUE ### Nothing blocks the request Your event is enqueued in under 5ms and drained by background workers — checkout never waits on a slow endpoint. WP-Cron → Action Scheduler → System Cron → External Cron (Pro). **02** / RETRY ### Failures fix themselves A flaky endpoint retries on its own — 1m → 2m → 4m → 8m, capped at 1h, 5 attempts by default. Tune the limit and the curve per webhook. **03** / LOG ### You can always see what happened Every attempt is recorded: payload, response, status, latency, attempt count. Filter by hook, endpoint, status, or time window. **04** / REPLAY ### Recover with one click Failed delivery? Replay it. Re-run a window. Re-evaluate a skipped event after fixing its conditions — from the admin or the REST API. **05** / IDENTITY ### Downstream dedup is free Every event carries a v4 UUID plus X-Event-Id and X-Webhook-Id headers, so receivers deduplicate and stay idempotent without inspecting the payload. **06** / FILTER ### Only the events that matter Fire only when order.total > $50, only when a product is in the cart, only when it's real traffic. Build as many rules as you need and group them with AND/OR matching — all free. ![](https://wpwebhooks.org/pricing/cron-hero-1920.webp) /The log ## Every move it makes, written down. Click any row to see the exact request body, response body, headers, and full attempt history. Failed? Replay it. And it's not just deliveries — every change the AI agent makes lands in the Activity History, so you always know who did what, human or machine. Status pending → processing → delivered / failed History Full attempt timeline per event Identity Event UUID · X-Event-Id · X-Webhook-Id Search By hook, endpoint, status, time window Audit Activity History for every admin, API, and agent action Smart retry & replay — failed delivery retried automatically, recoverable from the log with one click zero\_risk Nothing to lose: new webhooks start disabled, and every change can be undone. [Install Free Plugin →](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Read the docs](https://wpwebhooks.org/docs/) /For developers ## WordPress already broadcasts everything. That surface is your integration layer. WordPress already broadcasts everything important as an action — orders placed, forms submitted, posts published, users registered, subscriptions renewed. This plugin turns that action surface into **your** integration surface. Any **do\_action** on your site — including ones a plugin update ships next month — becomes a trigger you can map and send anywhere. No vendor SDK. No custom integration plugin per service. No glue code rotting in **functions.php**. The hook itself is the API — and the toolkit around it (queue, retries, idempotent headers, scoped REST tokens, a full delivery log, replay) makes the WordPress side of your stack finally look like the rest of it. The AI is optional. Everything the agent does you can click together by hand, script over the token-scoped REST API, or drive from an external agent via MCP — same engine either way. [Full plugin spec](https://wpwebhooks.org/wordpress-webhook-plugin/) · [API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) · [AI cost calculator](https://wpwebhooks.org/tools/llm-cost-calculator/) /Pricing ## Own the whole pipeline for free. Pay only to go further. Build with AI ships in the free plugin and starts with **55 credits on the house** — no key, no signup. After that, [a free Google AI Studio key](https://wpwebhooks.org/docs/get-google-ai-studio-api-key/) keeps it running, or a Pro plan includes hosted credits so you never manage a key at all. The free plugin runs production webhooks on unlimited sites — async queue, retries, the full delivery log, replay, the REST API, payload mapping, unlimited conditions with AND/OR groups, per-webhook retry and backoff, Code Glue PHP snippets and {{ field.path }} dynamic URL templates. Pro adds the two things that run on our servers: hosted AI credits, and a built-in External Cron that replaces WP-Cron entirely. /Open source Free The whole pipeline — every feature except hosted AI and External Cron. $0free forever [Download](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) → 55 free AI credits — no key, no signup → Build with AI — or bring your own key → Async queue + per-webhook retries & backoff → Code Glue — PHP before and after dispatch → Unlimited conditions with AND/OR groups → Dynamic URLs resolved from the payload → Full delivery log + one-click replay → REST API and MCP with scoped tokens → GPLv2 — unlimited installs /1 site Starter Hosted AI and External Cron for a single production site, staging and dev included. $249/yr Billed $249/yr — save $99 [Get Starter](https://api.wpwebhooks.org/checkout/starter?billing=yearly) → 5,000 AI credits/mo — build 200+ automations → Hosted AI — no API key to manage → Payload Library — 574 hooks it has already seen → API Docs Library — 312 APIs it reads before it builds → External Cron — reliable delivery without WP-Cron → 1 production site — staging & dev included → Unlimited local installs → Priority email support → 1 year of updates Most Popular /Most popular Business The right fit for growing teams and stores. $399/yr Billed $399/yr — save $141 [Get Business](https://api.wpwebhooks.org/checkout/business?billing=yearly) → 12,500 AI credits/mo — build 500+ automations → Hosted AI — no API key to manage → Payload Library — 574 hooks it has already seen → API Docs Library — 312 APIs it reads before it builds → External Cron — reliable delivery without WP-Cron → 10 production sites — staging & dev included → Unlimited local installs → Priority email support → 1 year of updates /75 sites Agency For agencies and platforms running at scale. $649/yr Billed $649/yr — save $179 [Get Agency](https://api.wpwebhooks.org/checkout/agency?billing=yearly) → 30,000 AI credits/mo — build 1,200+ automations → Hosted AI — no API key to manage → Payload Library — 574 hooks it has already seen → API Docs Library — 312 APIs it reads before it builds → External Cron — reliable delivery without WP-Cron → 75 production sites — staging & dev included → Unlimited local installs → Priority email support → 1 year of updates Prices exclude VAT. Any applicable tax is calculated and shown at checkout. /FAQ ## Things engineers always ask. Don't see yours? Open an issue on GitHub or check the full reference in the API docs. How do I set up a WordPress webhooks integration? + Pick the event, map the payload, point it at a URL. In the plugin you choose any do\_action already running on your site — a WooCommerce order status change, a Contact Form 7 submission, a user registration — and it becomes a trigger. The hook's arguments are normalized into clean JSON, you map the fields you want, add an HTTPS endpoint and any headers the receiver needs, and every fire is queued, dispatched, retried on failure and logged with the full request and response. There is no per-service connector to wait for, because the destination is just a URL. Does this send webhooks, receive them, or both? + It sends them. Webhook Actions is an outgoing webhook engine: WordPress events go out to endpoints you choose — an n8n, Make or Zapier node, a Slack or Discord incoming-webhook URL, an internal service, an AI agent API. It does not accept inbound webhooks as triggers. What it does expose is a token-authenticated REST API, so an external tool can read delivery logs, retry a failed event or toggle a webhook without WordPress credentials. What is Webhook Actions, and how does it relate to WP webhooks? + Webhook Actions is the free WordPress plugin behind this site. It turns any do\_action — from core, WooCommerce, Contact Form 7, Gravity Forms, or any other plugin — into a first-class event: captured, mapped into a JSON payload, dispatched to any HTTPS endpoint with retry and logging, and exposed over a token-authenticated REST API. It's how WP webhooks should have worked out of the box. Do I need an API key for Build with AI? Is it free to run? + No key needed to start. The free plugin grants 55 AI credits — about two complete automations — the first time you send a prompt to Build with AI, with no signup and no card. After that you have two ways to keep going. Bring your own model, always free: if your WordPress 7.0 site already has an AI provider connected (Settings → Connectors), the builder uses it with no extra setup and stores no keys — otherwise connect your own Anthropic, OpenAI, or Google key, and a free Google AI Studio key gives you Gemini at no cost in about two minutes. Or go Pro, where every plan includes a monthly pool of hosted credits (5,000 to 30,000/mo) that run on our servers, with nothing to connect and no per-token bill. If a provider gets rate-limited mid-build, the agent falls back to another connected provider and keeps going. Is my data safe with the AI builder? + Yes. Provider API keys are encrypted in the Credentials Vault and never returned over the API. Stored webhook credentials are never sent to the AI model, and captured payload values whose field names look sensitive (passwords, tokens, keys) are redacted before any prompt is built. The agent's changes run locally in the plugin — the model only proposes the plan, and nothing goes live without your confirmation. Can I trigger on any plugin’s action, including ones that don’t exist yet? + Yes. The plugin treats the entire WordPress action surface as your integration surface. Any do\_action — already running on your site, or shipped by an update next month — can be picked up as a trigger, mapped, and delivered. No SDK, no custom integration plugin, no vendor-specific code; the hook itself is the API. How does delivery actually work behind the scenes? + A do\_action fires inside WordPress. The plugin captures its arguments, normalizes PHP objects into clean JSON, applies your field mapping and conditions, queues the event in your own database, and dispatches it asynchronously — with retry, exponential backoff, idempotency headers (X-Event-Id, X-Webhook-Id), and a full request/response log on every attempt. Can I send to n8n, Make, Zapier, or HubSpot? + Yes. Any tool that accepts an HTTPS webhook works out of the box — n8n, Make, Zapier, Pipedrive, Notion, Airtable, Slack, or any internal service. Dynamic URL templates like https://api.hubapi.com/crm/objects/2026-03/deals/{{ \_hs\_deal\_id }} are free from 3.0.0, so a single webhook can PATCH a specific remote record per event. Does this work without WP-Cron? + Yes. When Action Scheduler is available (for example via WooCommerce), the plugin uses it for queue processing automatically — the same reliable background runner WooCommerce uses for production stores. Otherwise the queue runs on WP-Cron by default, but you are not tied to it: point a real system cron (server crontab) at the queue for time-based reliability, and Pro adds built-in External Cron — a managed runner that is automatically enabled with no configuration. You can also dispatch synchronously, sending the first attempt on the same request that fires the event (failures still fall back to the queue and retry normally) — handy for low-volume or time-sensitive hooks, though it blocks page load until that first attempt finishes, so the queued runners are still recommended under load. How does retry actually work? + Failed deliveries retry automatically with exponential backoff — roughly 1m → 2m → 4m → 8m, capped at one hour between attempts, with 5 attempts by default before an event is marked permanently failed. 5xx and 429 responses retry; 4xx and 3xx are marked permanently failed immediately. Every attempt is logged, and attempts and backoff can be tuned globally or per webhook — free since 3.0.0. What’s the difference between free and Pro? + From 3.0.0 the free plugin is the entire delivery pipeline, not a subset of it: queue, retries, logging, REST API and MCP, payload mapping, custom headers and URL params, the X-Event-Id / X-Webhook-Id headers, and — newly free — Code Glue PHP snippets, unlimited conditions with AND/OR groups, per-webhook retry and backoff settings, and {{ field.path }} URL templates. It also includes Build with AI: 55 free credits to start with no key at all, then your own OpenAI, Anthropic or Google key for as long as you like. Pro is the two things that cost real money to run: a monthly pool of hosted AI credits so Build with AI keeps working with no key of your own, and External Cron, a managed heartbeat that runs the delivery queue on a reliable schedule and switches itself on when the licence activates. The agent writes and assigns Code Glue snippets and multi-rule condition groups on the free plugin too — that is not a Pro line. /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org","description":"Describe the automation you want — the built-in AI agent plans, builds, and test-fires WordPress integrations on a queued, retried, logged delivery engine."} {"@context":"https://schema.org","@type":"SoftwareApplication","name":"WP Webhooks","applicationCategory":"BusinessApplication","operatingSystem":"WordPress","url":"https://wpwebhooks.org","description":"A free WordPress plugin with a built-in AI agent that plans, builds, and test-fires integrations and automations — on a queued, retried, logged webhook engine with replay and a token-authenticated REST API.","offers":[{"@type":"Offer","name":"Free","price":"0","priceCurrency":"USD"},{"@type":"Offer","name":"Pro","price":"29","priceCurrency":"USD","description":"per month, from (Starter)"}],"publisher":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl"}} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do I set up a WordPress webhooks integration?","acceptedAnswer":{"@type":"Answer","text":"Pick the event, map the payload, point it at a URL. In the plugin you choose any do_action already running on your site — a WooCommerce order status change, a Contact Form 7 submission, a user registration — and it becomes a trigger. The hook's arguments are normalized into clean JSON, you map the fields you want, add an HTTPS endpoint and any headers the receiver needs, and every fire is queued, dispatched, retried on failure and logged with the full request and response. There is no per-service connector to wait for, because the destination is just a URL."}},{"@type":"Question","name":"Does this send webhooks, receive them, or both?","acceptedAnswer":{"@type":"Answer","text":"It sends them. Webhook Actions is an outgoing webhook engine: WordPress events go out to endpoints you choose — an n8n, Make or Zapier node, a Slack or Discord incoming-webhook URL, an internal service, an AI agent API. It does not accept inbound webhooks as triggers. What it does expose is a token-authenticated REST API, so an external tool can read delivery logs, retry a failed event or toggle a webhook without WordPress credentials."}},{"@type":"Question","name":"What is Webhook Actions, and how does it relate to WP webhooks?","acceptedAnswer":{"@type":"Answer","text":"Webhook Actions is the free WordPress plugin behind this site. It turns any do_action — from core, WooCommerce, Contact Form 7, Gravity Forms, or any other plugin — into a first-class event: captured, mapped into a JSON payload, dispatched to any HTTPS endpoint with retry and logging, and exposed over a token-authenticated REST API. It's how WP webhooks should have worked out of the box."}},{"@type":"Question","name":"Do I need an API key for Build with AI? Is it free to run?","acceptedAnswer":{"@type":"Answer","text":"No key needed to start. The free plugin grants 55 AI credits — about two complete automations — the first time you send a prompt to Build with AI, with no signup and no card. After that you have two ways to keep going. Bring your own model, always free: if your WordPress 7.0 site already has an AI provider connected (Settings → Connectors), the builder uses it with no extra setup and stores no keys — otherwise connect your own Anthropic, OpenAI, or Google key, and a free Google AI Studio key gives you Gemini at no cost in about two minutes. Or go Pro, where every plan includes a monthly pool of hosted credits (5,000 to 30,000/mo) that run on our servers, with nothing to connect and no per-token bill. If a provider gets rate-limited mid-build, the agent falls back to another connected provider and keeps going."}},{"@type":"Question","name":"Is my data safe with the AI builder?","acceptedAnswer":{"@type":"Answer","text":"Yes. Provider API keys are encrypted in the Credentials Vault and never returned over the API. Stored webhook credentials are never sent to the AI model, and captured payload values whose field names look sensitive (passwords, tokens, keys) are redacted before any prompt is built. The agent's changes run locally in the plugin — the model only proposes the plan, and nothing goes live without your confirmation."}},{"@type":"Question","name":"Can I trigger on any plugin’s action, including ones that don’t exist yet?","acceptedAnswer":{"@type":"Answer","text":"Yes. The plugin treats the entire WordPress action surface as your integration surface. Any do_action — already running on your site, or shipped by an update next month — can be picked up as a trigger, mapped, and delivered. No SDK, no custom integration plugin, no vendor-specific code; the hook itself is the API."}},{"@type":"Question","name":"How does delivery actually work behind the scenes?","acceptedAnswer":{"@type":"Answer","text":"A do_action fires inside WordPress. The plugin captures its arguments, normalizes PHP objects into clean JSON, applies your field mapping and conditions, queues the event in your own database, and dispatches it asynchronously — with retry, exponential backoff, idempotency headers (X-Event-Id, X-Webhook-Id), and a full request/response log on every attempt."}},{"@type":"Question","name":"Can I send to n8n, Make, Zapier, or HubSpot?","acceptedAnswer":{"@type":"Answer","text":"Yes. Any tool that accepts an HTTPS webhook works out of the box — n8n, Make, Zapier, Pipedrive, Notion, Airtable, Slack, or any internal service. Dynamic URL templates like https://api.hubapi.com/crm/objects/2026-03/deals/{{ _hs_deal_id }} are free from 3.0.0, so a single webhook can PATCH a specific remote record per event."}},{"@type":"Question","name":"Does this work without WP-Cron?","acceptedAnswer":{"@type":"Answer","text":"Yes. When Action Scheduler is available (for example via WooCommerce), the plugin uses it for queue processing automatically — the same reliable background runner WooCommerce uses for production stores. Otherwise the queue runs on WP-Cron by default, but you are not tied to it: point a real system cron (server crontab) at the queue for time-based reliability, and Pro adds built-in External Cron — a managed runner that is automatically enabled with no configuration. You can also dispatch synchronously, sending the first attempt on the same request that fires the event (failures still fall back to the queue and retry normally) — handy for low-volume or time-sensitive hooks, though it blocks page load until that first attempt finishes, so the queued runners are still recommended under load."}},{"@type":"Question","name":"How does retry actually work?","acceptedAnswer":{"@type":"Answer","text":"Failed deliveries retry automatically with exponential backoff — roughly 1m → 2m → 4m → 8m, capped at one hour between attempts, with 5 attempts by default before an event is marked permanently failed. 5xx and 429 responses retry; 4xx and 3xx are marked permanently failed immediately. Every attempt is logged, and attempts and backoff can be tuned globally or per webhook — free since 3.0.0."}},{"@type":"Question","name":"What’s the difference between free and Pro?","acceptedAnswer":{"@type":"Answer","text":"From 3.0.0 the free plugin is the entire delivery pipeline, not a subset of it: queue, retries, logging, REST API and MCP, payload mapping, custom headers and URL params, the X-Event-Id / X-Webhook-Id headers, and — newly free — Code Glue PHP snippets, unlimited conditions with AND/OR groups, per-webhook retry and backoff settings, and {{ field.path }} URL templates. It also includes Build with AI: 55 free credits to start with no key at all, then your own OpenAI, Anthropic or Google key for as long as you like. Pro is the two things that cost real money to run: a monthly pool of hosted AI credits so Build with AI keeps working with no key of your own, and External Cron, a managed heartbeat that runs the delivery queue on a reliable schedule and switches itself on when the licence activates. The agent writes and assigns Code Glue snippets and multi-rule condition groups on the free plugin too — that is not a Pro line."}}]} ``` --- --- title: "WordPress Webhook Plugin: Async Queue, Retry & Logs" description: "Free WordPress webhook plugin: turn any do_action into a webhook. Async queue, retries with backoff, full delivery log, replay, and a REST API." url: "https://wpwebhooks.org/wordpress-webhook-plugin/" --- /Webhook Actions · by Flow Systems · v3.2.0 # Very Powerful WordPress webhook plugin. Describe the integration. The built-in agent builds it, test-fires it, and hands you the switch. [Install Free Plugin →](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the live preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) ●  Free & open source ●  GPLv2 ●  5★ · 2 reviews ![A vast synthetic face of pale ceramic inlaid with glowing green circuit tracery, growing out of a mass of thick black cabling in a dark server hall, its lit eyes fixed on the viewer.](https://wpwebhooks.org/pricing/ai-hero-1920.webp) / Build with AI ## It has already seen the payload and read the API. A real example of the event — yours, or one of ours for an event your site has never fired — and the destination's current API reference, read before a single field is mapped. - Payload Library 574 hook payloads from WordPress core, WooCommerce and the major form plugins - API Docs Library 312 APIs — HubSpot, OpenAI, Slack, Stripe — read on our server, not remembered - Free credits 55 on every install. No key, no signup, no card. [Every API it reads →](https://wpwebhooks.org/api-library/) /What you can do ## Say what you want. Get a working integration. Contact form → Google Sheets "When a Contact Form 7 form is submitted, add a row to my Google Sheet." [Build with AI →](https://wpwebhooks.org/docs/build-with-ai/) WooCommerce order → HubSpot deal "Create a HubSpot deal every time an order is placed." [Worked example →](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) Gravity Forms → anywhere "Send Gravity Forms submissions as JSON to my n8n endpoint." [Worked example →](https://wpwebhooks.org/examples/gravity-forms-webhooks/) New user → Slack "When a user registers, notify my Slack channel via webhook." [Build with AI →](https://wpwebhooks.org/docs/build-with-ai/) Customer + order sync → HubSpot "Keep HubSpot in sync with my customers and their orders." [Worked example →](https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/) Drive it from Claude Code "Create a webhook for completed orders — sent by your AI assistant over the REST API." [Worked example →](https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/) /Inside the plugin ## Ten tabs. Every operation you'll ever do. The same UI you get in `wp-admin` after install. Click a tab. ![A colossal rusted delivery robot standing upright and lit in a dark industrial hall, its single optic throwing a green beam through the rain, fed by an armoured supply line running up and out through the ceiling.](https://wpwebhooks.org/pricing/cron-hero-1920.webp) / The engine underneath ## Queued. Retried. Logged. Every delivery leaves through a persistent queue, retries on its own schedule, and lands in a log you can grep — and replay. - Async queue WP-Cron, Action Scheduler or System Cron — whichever the site has, picked up automatically - Retries & backoff Exponential, linear or fixed, with a limit per webhook - Delivery log Payload, every attempt, the response body. One click to replay. - External Cron Pro — fires from outside the site, on its own clock, whether anyone visits or not [Anatomy of a delivery →](#anatomy) /Anatomy of a delivery ## Every event has a paper trail. ``` // log entry 156 — woocommerce_order_status_completed "event_uuid": "37116b42-7a4c-4c6d-8aa7-ea4bc3c13e4d", "trigger_name": "woocommerce_order_status_completed", "request_url": "https://n8n.flowsystems.pl/webhook/126cc22b…", "status": "success", "http_code": 200, "duration_ms": 223, "attempt_history": [ { "attempt": 0, "http_code": 200, "duration_ms": 223 } ], "next_attempt_at": null ``` FIG — Anatomy of a delivery Identity Every delivery names its source: **X-Webhook-Id** is the config’s UUID, so one endpoint can serve several webhooks. Provenance Logged with its full payload. Filter by **X-Webhook-Id**, event UUID, target URL, status or date. Recovery Failed at attempt 1, recovered at attempt 2 — both in the log, with a **replay** button anyway. Idempotency A stable **X-Event-Id** per event, so receivers deduplicate retries without reading the payload. /Compatible with ## Any action. Any plugin. Any custom hook. WooCommerce✓ order\_status\_completed · new\_order · refunded · 30+ Gravity Forms✓ gform\_after\_submission · gform\_pre\_submission Contact Form 7✓ wpcf7\_mail\_sent · wpcf7\_before\_send\_mail (built-in) IvyForms✓ ivyforms/form/after\_submission (built-in) Easy Digital Downloads✓ edd\_complete\_purchase · edd\_refund\_order ACF✓ acf/save\_post · acf/update\_value WordPress Core✓ user\_register · post\_updated · comment\_post · 200+ Custom hooks✓ any do\_action() — including your own REST API✓ GET · POST · PUT · PATCH · DELETE /fswa/v1/\* Webhook Chains✓ upstream 2xx → trigger downstream webhooks Sync execution✓ inline delivery · auto queue fallback on failure AI agents / CI✓ scoped tokens · read · operational · full · agent /Spec sheet ## Concrete numbers, not feature dots. | Capability | Free | Pro / Agency | | --- | --- | --- | | Build with AI agent | Included — nothing to connect to start | Same | | AI credits | 55 free credits · then your own key | 5,000 – 30,000/mo hosted (or your own key) | | Payload Library | 574 hooks · on the free trial | Same, on hosted credits | | API Docs Library | 312 APIs · on the free trial | Same, on hosted credits | | Async queue | WP-Cron / Action Scheduler (auto) | Same + External Cron (Pro) | | Retry attempts | Configurable per webhook (5 default) | Same | | Backoff schedule | Exponential, linear or fixed · per webhook | Same | | Conditions | Unlimited · AND/OR groups · AI-built | Same | | Dynamic URL | {{ field.path }} templates + PHP filter | Same | | Code Glue | PHP snippets · AI writes and assigns them | Same | | Webhook Chains | Yes | Yes | | Sites | Unlimited (GPLv2) | Starter 1 · Business 10 · Agency 75 | | Replay | One-click (incl. skipped) | One-click + bulk | | HTTP methods | GET · POST · PUT · PATCH · DELETE | Same | | Support | Community / GitHub | Priority email | Pricing for Pro and Agency tiers on the [pricing page →](https://wpwebhooks.org/pricing/) /Install ## Pick your install path. ⊟ wp-admin ``` Plugins → Add New Search "Webhook Actions" Install → Activate ``` Standard install. Auto-updates via wp.org. ⌬ Upload .zip ``` Plugins → Add New Upload Plugin → choose webhook-actions.zip → Activate ``` For pre-release builds and Pro distribution. ⌬ WP-CLI ``` $ wp plugin install \ flowsystems-webhook-actions \ --activate ``` For scripted installs, CI/CD pipelines, and server provisioning. /In the wild ## Featured in a WordPress plugin roundup. ## Build automations and integrations with AI help. Search "Webhook Actions" on wp.org, or paste the install command. [Install Plugin →](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [View on GitHub](https://github.com/flowsystems-pl/wordpress-webhook-actions) $ wp plugin install flowsystems-webhook-actions --activate v3.2.0 · 2026-09-12 · [Full changelog →](https://wpwebhooks.org/changelog/) ## Structured data ```json {"@context":"https://schema.org","@type":"SoftwareApplication","name":"Webhook Actions by Flow Systems","applicationCategory":"WordPress Plugin","operatingSystem":"WordPress 6.0+","description":"Free WordPress AI webhook plugin that turns any do_action into a queued, retried, logged, replayable webhook. Describe the integration and the built-in AI agent builds it. Async queue, full delivery log, REST API, scoped tokens.","url":"https://wpwebhooks.org/wordpress-webhook-plugin/","offers":{"@type":"Offer","price":"0","priceCurrency":"USD"},"aggregateRating":{"@type":"AggregateRating","ratingValue":"5","reviewCount":"2"}} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/webhook-delivery-anatomy.png","caption":"FIG — Anatomy of a delivery","description":"A WordPress do_action fires the Webhook Actions listener, which builds the payload, applies conditions, and writes to a persistent queue. The queue runner (WP-Cron upgrading to Action Scheduler or system cron) posts to the endpoint: a 2xx is marked delivered and logged, a 5xx or 429 retries with exponential backoff, and a 4xx or 3xx is permanently failed but replayable.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "REST API Reference" description: "Complete REST API reference for the Webhook Actions WordPress plugin — 77 endpoints across 16 resource groups. Authentication, parameters, request bodies, response schemas, and curl examples." url: "https://wpwebhooks.org/webhook-wordpress-plugin-api/" --- [WP Webhooks](https://wpwebhooks.org/)/ API Reference # REST API Reference v2.4.0 · 77 endpoints · 16 resource groups REST API for the free [Webhook Actions](https://wordpress.org/plugins/flowsystems-webhook-actions/) WordPress plugin — hosted AI credits and External Cron require a [Pro license](https://wpwebhooks.org/pricing/). ## / Authentication Base URL `https://your-site.com/wp-json/fswa/v1` Pass an API token via any of these three methods: X-FSWA-Token header `X-FSWA-Token: ` Recommended. Keeps the token out of server logs and URLs. Authorization: Bearer `Authorization: Bearer ` Standard Bearer token format, compatible with most HTTP clients. Query parameter `?api_token=` Convenient for quick browser testing or webhook callbacks. Token Scopes `read` GET endpoints — list, get, stats, health, triggers, schemas. `operational` Read + toggle webhooks, retry/replay logs, execute queue jobs. `full` Operational + create, update, delete. Reveals stored auth secrets. Token management requires WordPress admin session. `agent` Same write access as full, but can never reveal a webhook's auth\_header or any Credentials Vault secret. Intended for AI assistants. ## Webhooks 8 endpoints Create and manage webhook endpoints. GET `/webhooks` List webhooks + List webhooks Returns all webhooks. Requires \`read\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | only\_enabled | query | boolean | — | default: false | Responses 200 Array of webhooks. 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: your-token" ``` POST `/webhooks` Create a webhook + Create a webhook Creates a new webhook. Requires \`full\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · required name string required endpoint\_url string required auth\_header string http\_method string GET | POST | PUT | PATCH | DELETE HTTP method used for delivery. GET and DELETE send no body — configure \`url\_params\` to pass payload data as query parameters. custom\_headers array Extra request headers. Values support dot-notation paths into the outgoing payload or literal strings. items: key string value string Dot-path (e.g. \`event.id\`) or static string. url\_params array Query parameters appended to the URL. Required for GET/DELETE to carry payload data. items: key string value string Dot-path (e.g. \`order.id\`) or static string. is\_enabled boolean is\_synchronous boolean When true, the webhook fires inline during the WordPress request that triggered it, bypassing the queue for the first attempt. Retryable failures fall back to the async queue starting at attempt 1. triggers array auth\_credential\_id integer ID of a Credentials Vault entry to use for authorization. Takes precedence over \`auth\_header\`. Set null to clear. Responses 200 Created webhook. ``` { "id": 1, "webhook_uuid": "550e8400-e29b-41d4-a716-446655440000", "name": "Order to n8n", "endpoint_url": "https://n8n.example.com/webhook/abc123", "auth_header": "Bearer secret123", "http_method": "GET" } ``` 400 Validation error. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "name": "Order to n8n", "endpoint_url": "https://n8n.example.com/webhook/abc123", "auth_header": "Bearer secret123", "http_method": "GET", "custom_headers": "" }' ``` GET `/webhooks/{id}` Get a webhook + Get a webhook Requires \`read\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Webhook. ``` { "id": 1, "webhook_uuid": "550e8400-e29b-41d4-a716-446655440000", "name": "Order to n8n", "endpoint_url": "https://n8n.example.com/webhook/abc123", "auth_header": "Bearer secret123", "http_method": "GET" } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/webhooks/id" \ -H "X-FSWA-Token: your-token" ``` PATCH `/webhooks/{id}` Update a webhook + Update a webhook Updates a webhook. All fields are optional. Requires \`full\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · optional name string required endpoint\_url string required auth\_header string http\_method string GET | POST | PUT | PATCH | DELETE HTTP method used for delivery. GET and DELETE send no body — configure \`url\_params\` to pass payload data as query parameters. custom\_headers array Extra request headers. Values support dot-notation paths into the outgoing payload or literal strings. items: key string value string Dot-path (e.g. \`event.id\`) or static string. url\_params array Query parameters appended to the URL. Required for GET/DELETE to carry payload data. items: key string value string Dot-path (e.g. \`order.id\`) or static string. is\_enabled boolean is\_synchronous boolean When true, the webhook fires inline during the WordPress request that triggered it, bypassing the queue for the first attempt. Retryable failures fall back to the async queue starting at attempt 1. triggers array auth\_credential\_id integer ID of a Credentials Vault entry to use for authorization. Takes precedence over \`auth\_header\`. Set null to clear. Responses 200 Updated webhook. ``` { "id": 1, "webhook_uuid": "550e8400-e29b-41d4-a716-446655440000", "name": "Order to n8n", "endpoint_url": "https://n8n.example.com/webhook/abc123", "auth_header": "Bearer secret123", "http_method": "GET" } ``` 400 Validation error. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X PATCH "https://your-site.com/wp-json/fswa/v1/webhooks/id" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "name": "Order to n8n", "endpoint_url": "https://n8n.example.com/webhook/abc123", "auth_header": "Bearer secret123", "http_method": "GET", "custom_headers": "" }' ``` DELETE `/webhooks/{id}` Delete a webhook + Delete a webhook Requires \`full\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Deleted. ``` { "deleted": true, "id": 0 } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X DELETE "https://your-site.com/wp-json/fswa/v1/webhooks/id" \ -H "X-FSWA-Token: your-token" ``` POST `/webhooks/{id}/toggle` Toggle webhook on/off + Toggle webhook on/off Flips the \`is\_enabled\` flag. Requires \`operational\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Updated webhook. ``` { "id": 1, "webhook_uuid": "550e8400-e29b-41d4-a716-446655440000", "name": "Order to n8n", "endpoint_url": "https://n8n.example.com/webhook/abc123", "auth_header": "Bearer secret123", "http_method": "GET" } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/webhooks/id/toggle" \ -H "X-FSWA-Token: your-token" ``` GET `/webhooks/{webhook_id}/logs` List logs for a specific webhook + List logs for a specific webhook Returns paginated delivery logs scoped to a single webhook. Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | page | query | integer | — | default: 1 | | per\_page | query | integer | — | default: 20 | Responses 200 Array of log entries. 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/webhooks/webhook_id/logs" \ -H "X-FSWA-Token: your-token" ``` POST `/webhooks/{id}/test` Send a test delivery + Send a test delivery Fires a test delivery for a webhook. The webhook must have at least one trigger configured. Requires \`full\` scope or admin session. \*\*Payload sources:\*\* - \`captured\` — the raw captured example payload for the trigger (no mapping applied) - \`mapped\` — captured payload with field mapping applied - \`pre\_glue\` — mapped payload with the pre-dispatch Code Glue snippet applied - \`full\_glue\` — same as \`pre\_glue\`; post-dispatch snippet also runs after delivery - \`custom\` — supply an arbitrary payload via the \`payload\` field \*\*Modes:\*\* - \`now\` — runs synchronously, returns the full log entry immediately - \`queue\` — enqueues the job and returns the job ID Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · required trigger string Trigger name to test. Defaults to the first configured trigger if omitted. payload\_source string captured | mapped | pre\_glue | full\_glue | custom Where to source the test payload from. payload object Custom payload. Required when \`payload\_source\` is \`custom\`. object Custom payload. Required when \`payload\_source\` is \`custom\`. mode string now | queue \`now\` returns the result immediately; \`queue\` enqueues for async processing. Responses 200 Test result. 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 422 Webhook has no triggers configured, or no captured payload found for the trigger. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/webhooks/id/test" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "trigger": "woocommerce_order_status_completed", "payload_source": "captured", "payload": "", "mode": "now" }' ``` ## Logs 8 endpoints View delivery logs, retry failures, and replay events. GET `/logs` List delivery logs + List delivery logs Returns paginated webhook delivery logs. Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | page | query | integer | — | default: 1 | | per\_page | query | integer | — | default: 20 | | status | query | string | — | success | error | retry | pending | permanently\_failed | test | skipped | | webhook\_id | query | integer | — | | | trigger\_name | query | string | — | | | webhook\_uuid | query | string | — | Filter by webhook UUID / \`X-Webhook-Id\` value (partial match). | | event\_uuid | query | string | — | Filter by event UUID (exact match). | | target\_url | query | string | — | Filter by target URL (partial match). | | date\_from | query | string | — | Filter from this UTC datetime (format: \`YYYY-MM-DD HH:MM:SS\`). | | date\_to | query | string | — | Filter until this UTC datetime (format: \`YYYY-MM-DD HH:MM:SS\`). | | chain\_id | query | integer | — | Filter to entries triggered by any link in this chain (resolves to the chain's synthetic fswa\_chain\_link:N trigger names). | Responses 200 Array of log entries. 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/logs" \ -H "X-FSWA-Token: your-token" ``` DELETE `/logs` Bulk delete logs + Bulk delete logs Deletes all log entries older than the specified number of days. Requires \`full\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | older\_than\_days | query | integer | yes | | Responses 200 Number of deleted records. ``` { "deleted": 0, "older_than": null } ``` 400 Missing older\_than\_days parameter. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X DELETE "https://your-site.com/wp-json/fswa/v1/logs" \ -H "X-FSWA-Token: your-token" ``` GET `/logs/stats` Delivery statistics + Delivery statistics Returns delivery statistics for the specified period. Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | days | query | integer | — | default: 7 | | webhook\_id | query | integer | — | | Responses 200 Delivery stats. ``` { "total": 0, "success": 0, "error": 0, "retry": 0, "pending": 0, "permanently_failed": 0 } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/logs/stats" \ -H "X-FSWA-Token: your-token" ``` POST `/logs/bulk-retry` Bulk retry failed logs + Bulk retry failed logs Resets the associated queue jobs for multiple failed log entries so they will be retried. Requires \`operational\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · required ids array required Array of log IDs to retry. Responses 200 Retry result. ``` { "retried": 0, "skipped": 0 } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/logs/bulk-retry" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "ids": "" }' ``` GET `/logs/{id}` Get a log entry + Get a log entry Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Log entry. ``` { "id": 0, "webhook_id": 0, "webhook_uuid": null, "trigger_name": "woocommerce_order_status_completed", "event_uuid": "550e8400-e29b-41d4-a716-446655440000", "event_timestamp": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/logs/id" \ -H "X-FSWA-Token: your-token" ``` DELETE `/logs/{id}` Delete a log entry + Delete a log entry Requires \`full\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Deleted. ``` { "deleted": true, "id": 0 } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X DELETE "https://your-site.com/wp-json/fswa/v1/logs/id" \ -H "X-FSWA-Token: your-token" ``` POST `/logs/{id}/retry` Retry a failed log + Retry a failed log Resets the queue job linked to a failed log entry so it will be retried on the next cron run. Only \`error\` or \`permanently\_failed\` logs can be retried. Requires \`operational\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Retry queued. ``` { "success": false, "job_id": 0 } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 409 Conflict — e.g., job is already processing or not in a retryable state. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/logs/id/retry" \ -H "X-FSWA-Token: your-token" ``` POST `/logs/{id}/replay` Replay a successful log + Replay a successful log Creates a new queue job using the original payload from a successful log entry. The replay reuses the existing log entry (new attempt added to the attempt history). Only \`success\` logs can be replayed. Requires \`operational\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Replay queued. ``` { "success": false, "job_id": 0 } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 409 Conflict — e.g., job is already processing or not in a retryable state. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/logs/id/replay" \ -H "X-FSWA-Token: your-token" ``` ## Queue 5 endpoints Inspect and control the delivery queue. GET `/queue` List queue jobs + List queue jobs Returns paginated queue jobs. Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | page | query | integer | — | default: 1 | | per\_page | query | integer | — | default: 20 | | status | query | string | — | pending | processing | completed | failed | permanently\_failed | | webhook\_id | query | integer | — | | | webhook\_uuid | query | string | — | Filter by webhook UUID / \`X-Webhook-Id\` value (partial match). | | event\_uuid | query | string | — | Filter by event UUID (exact match). | | target\_url | query | string | — | Filter by target URL (partial match). | | date\_from | query | string | — | Filter from this UTC datetime (format: \`YYYY-MM-DD HH:MM:SS\`). | | date\_to | query | string | — | Filter until this UTC datetime (format: \`YYYY-MM-DD HH:MM:SS\`). | | chain\_id | query | integer | — | Filter to entries triggered by any link in this chain (resolves to the chain's synthetic fswa\_chain\_link:N trigger names). | Responses 200 Paginated list of queue jobs. 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/queue" \ -H "X-FSWA-Token: your-token" ``` GET `/queue/stats` Queue statistics + Queue statistics Returns current queue counts by status. Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Queue stats. ``` { "total": 0, "pending": 0, "processing": 0, "completed": 0, "permanently_failed": 0, "due_now": 0 } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/queue/stats" \ -H "X-FSWA-Token: your-token" ``` POST `/queue/execute` Execute a queue job immediately + Execute a queue job immediately Locks and executes a specific pending job immediately, bypassing the scheduled cron run. Requires \`operational\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · required id integer required Queue job ID. Responses 200 Execution result. ``` { "success": false, "rescheduled": false, "permanently_failed": false, "message": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 409 Conflict — e.g., job is already processing or not in a retryable state. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/queue/execute" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "id": 0 }' ``` POST `/queue/retry` Reset and retry a failed queue job + Reset and retry a failed queue job Resets a \`permanently\_failed\` or \`pending\` job to be retried. Requires \`operational\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · required id integer required Responses 200 Job queued for retry. ``` { "success": true, "message": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 409 Conflict — e.g., job is already processing or not in a retryable state. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/queue/retry" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "id": 0 }' ``` POST `/queue/delete` Delete a queue job + Delete a queue job Permanently removes a queue job. Cannot delete a job that is currently processing. Requires \`full\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · required id integer required Responses 200 Job deleted. ``` { "success": true, "message": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 409 Conflict — e.g., job is already processing or not in a retryable state. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/queue/delete" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "id": 0 }' ``` ## Health 1 endpoint System health and delivery statistics. GET `/health` System health and statistics + System health and statistics Returns aggregate delivery stats, queue health, velocity metrics, and observability indicators. Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Health stats. ``` { "success_rate": 98.5, "has_data": false, "webhooks": null, "logs": null, "queue": null, "velocity": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/health" \ -H "X-FSWA-Token: your-token" ``` ## Triggers 1 endpoint Browse available WordPress action triggers. GET `/triggers` List available triggers + List available triggers Returns suggested triggers plus all currently registered WordPress hooks (excluding internal/UI hooks). Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Trigger list. ``` { "triggers": [], "grouped": null, "categories": null, "allowCustom": false } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/triggers" \ -H "X-FSWA-Token: your-token" ``` ## Schemas 6 endpoints Configure payload mapping for webhook/trigger pairs. GET `/schemas/webhook/{id}` List payload schemas for a webhook + List payload schemas for a webhook Returns all stored payload schemas (one per trigger) for the given webhook. Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Array of schemas. 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/schemas/webhook/id" \ -H "X-FSWA-Token: your-token" ``` GET `/schemas/webhook/{id}/trigger/{trigger}` Get payload schema + Get payload schema Returns the payload schema for a specific webhook/trigger pair. Returns an empty schema structure if none has been configured. Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Schema. ``` { "id": 0, "webhook_id": 0, "trigger_name": null, "example_payload": null, "field_mapping": null, "conditions": { "enabled": true, "type": "and", "rules": [ { "field": "args.0.form_id", "operator": "equals", "value": "1", "cast": "number" }, { "type": "group", "match": "or", "rules": [ { "field": "args.0.4", "operator": "equals", "value": "sales" }, { "field": "args.0.4", "operator": "equals", "value": "support" } ] } ] } } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/schemas/webhook/id/trigger/trigger" \ -H "X-FSWA-Token: your-token" ``` PUT `/schemas/webhook/{id}/trigger/{trigger}` Update payload schema + Update payload schema Creates or updates the payload schema for a webhook/trigger pair. Requires \`full\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · optional field\_mapping object Field mapping configuration. Use \`mappings\` to extract and rename individual fields from the raw hook payload using dot-notation paths. Use \`excluded\` to drop top-level keys from the output. Set \`includeUnmapped\` to \`true\` to pass through any fields not covered by \`mappings\` or \`excluded\`. mappings array items: source string required Dot-notation path into the raw payload (e.g. \`args.0.1\`, \`args.0.ip\`). target string required Dot-notation path for the key in the outgoing payload (e.g. \`name\`, \`user\_ip\`, \`data.tel\`). cast string number | string | boolean | stringify Optional type cast applied to the extracted value before mapping. Use \`stringify\` to JSON-encode arrays or objects into a string. excluded array Top-level keys to drop from the output after mapping (e.g. \`\["args"\]\`). includeUnmapped boolean Pass through top-level fields not covered by mappings or excluded. include\_user\_data boolean Whether to auto-include WordPress user data for user-related triggers. conditions object Conditional delivery rules. Pass \`null\` to remove. Unlimited rules and AND/OR condition groups — free since 3.0.0. enabled boolean type string and | or Match type. Free tier is locked to \`and\`. rules array Responses 200 Updated schema. ``` { "id": 0, "webhook_id": 0, "trigger_name": null, "example_payload": null, "field_mapping": null, "conditions": { "enabled": true, "type": "and", "rules": [ { "field": "args.0.form_id", "operator": "equals", "value": "1", "cast": "number" }, { "type": "group", "match": "or", "rules": [ { "field": "args.0.4", "operator": "equals", "value": "sales" }, { "field": "args.0.4", "operator": "equals", "value": "support" } ] } ] } } ``` 400 No updatable fields provided. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X PUT "https://your-site.com/wp-json/fswa/v1/schemas/webhook/id/trigger/trigger" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "field_mapping": { "mappings": [ { "source": "args.0.1", "target": "name" }, { "source": "args.0.2", "target": "email" }, { "source": "args.0.3", "target": "tel" }, { "source": "args.0.4", "target": "type" }, { "source": "args.0.5", "target": "message" }, { "source": "args.0.ip", "target": "user_ip" } ], "excluded": [ "args" ], "includeUnmapped": true }, "include_user_data": false, "conditions": { "enabled": true, "type": "and", "rules": [ { "field": "order.status", "operator": "equals", "value": "completed" }, { "field": "order.total", "operator": "greater_than", "value": "100", "cast": "number" } ] } }' ``` DELETE `/schemas/webhook/{id}/trigger/{trigger}` Delete payload schema + Delete payload schema Removes the payload schema for a webhook/trigger pair. Requires \`full\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Deleted. ``` { "deleted": false } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X DELETE "https://your-site.com/wp-json/fswa/v1/schemas/webhook/id/trigger/trigger" \ -H "X-FSWA-Token: your-token" ``` POST `/schemas/webhook/{id}/trigger/{trigger}/capture` Reset payload capture + Reset payload capture Clears the stored example payload so the next webhook delivery will be captured as the new example. Requires \`full\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Capture reset. ``` { "reset": false, "schema": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/schemas/webhook/id/trigger/trigger/capture" \ -H "X-FSWA-Token: your-token" ``` GET `/schemas/user-triggers` List triggers that support user data enrichment + List triggers that support user data enrichment Returns the list of trigger hook names that support automatic user data enrichment (when \`include\_user\_data\` is enabled). Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 User-enrichable triggers. ``` { "triggers": [ "user_register", "profile_update", "wp_login", "wp_logout" ] } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/schemas/user-triggers" \ -H "X-FSWA-Token: your-token" ``` ## API Tokens 5 endpoints Manage API tokens. Requires WordPress admin login — tokens cannot manage themselves on their own. GET `/tokens` List API tokens + List API tokens Returns all API tokens (never exposes the token secret). \*\*Requires WordPress admin login.\*\* API tokens cannot access this endpoint. Auth Required WordPress admin session Responses 200 Array of tokens. 401 Not logged in as admin. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/tokens" \ -H "X-FSWA-Token: your-token" ``` POST `/tokens` Create an API token + Create an API token Creates a new API token. The plaintext token is returned \*\*once\*\* in the response and cannot be retrieved later. \*\*Requires WordPress admin login.\*\* Auth Required WordPress admin session Request Body · required name string required scope string required read | operational | full | agent expires\_at string Optional UTC expiry. Omit for no expiry. Responses 200 Created token with plaintext secret. 400 Validation error. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 401 Not logged in as admin. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/tokens" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "name": "n8n integration", "scope": "read", "expires_at": "2027-01-01 00:00:00" }' ``` POST `/tokens/{id}/rotate` Rotate an API token + Rotate an API token Issues a new token secret while preserving the token's name, scope, and other settings. The old secret is immediately invalidated. The new plaintext token is returned \*\*once\*\*. Optionally update \`expires\_at\` in the same request. \*\*Requires WordPress admin login.\*\* Auth Required WordPress admin session Request Body · optional expires\_at string If present, updates the expiry. Pass \`null\` to remove expiry. Omit to keep the current expiry. Responses 200 Rotated token with new plaintext secret. 401 Not logged in as admin. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/tokens/id/rotate" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "expires_at": "2027-06-01 00:00:00" }' ``` PATCH `/tokens/{id}` Update token expiry + Update token expiry Updates \`expires\_at\` without rotating the token secret. Pass \`null\` to remove the expiry. \*\*Requires WordPress admin login.\*\* Auth Required WordPress admin session Request Body · required expires\_at string Responses 200 Updated token. ``` { "id": 0, "name": "n8n integration", "scope": "read", "token_hint": "ab12", "expires_at": null, "last_used_at": null } ``` 401 Not logged in as admin. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X PATCH "https://your-site.com/wp-json/fswa/v1/tokens/id" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "expires_at": "2027-01-01 00:00:00" }' ``` DELETE `/tokens/{id}` Delete an API token + Delete an API token Permanently deletes an API token. \*\*Requires WordPress admin login.\*\* Auth Required WordPress admin session Responses 200 Deleted. ``` { "deleted": true, "id": 0 } ``` 401 Not logged in as admin. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X DELETE "https://your-site.com/wp-json/fswa/v1/tokens/id" \ -H "X-FSWA-Token: your-token" ``` ## Credentials Vault 7 endpoints Encrypted, reusable authentication secrets referenced by webhooks. Secrets are write-only — never returned by the API. GET `/credentials` List credentials + List credentials Lists vault credentials (metadata + masked hint only). Requires \`full\`/\`agent\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Array of credentials. 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/credentials" \ -H "X-FSWA-Token: your-token" ``` POST `/credentials` Create a credential + Create a credential Creates an encrypted credential. The secret is write-only — the response returns metadata only. Requires \`full\`/\`agent\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · required name string required type string required bearer | basic | api\_key | custom header\_name string Required for \`api\_key\`/\`custom\` (defaults to \`Authorization\`). secret string Token/key for bearer, api\_key, or custom types. username string Username for \`basic\` type (e.g. WordPress username for an Application Password). password string Password/application-password for \`basic\` type. Responses 200 Created credential (no secret). ``` { "id": 3, "name": "HubSpot PAT", "type": "bearer", "header_name": "X-API-Key", "hint": "Bearer ****1234", "created_at": null } ``` 400 Validation error. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 409 A credential with this name already exists. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/credentials" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "name": "HubSpot PAT", "type": "bearer", "header_name": "X-API-Key", "secret": "sk_live_xxx", "username": "" }' ``` GET `/credentials/{id}` Get a credential + Get a credential Returns credential metadata (no secret). Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Credential. ``` { "id": 3, "name": "HubSpot PAT", "type": "bearer", "header_name": "X-API-Key", "hint": "Bearer ****1234", "created_at": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/credentials/id" \ -H "X-FSWA-Token: your-token" ``` PUT `/credentials/{id}` Update a credential + Update a credential Updates a credential. Omit secret fields to keep the current value; supplying them re-encrypts. Requires \`full\`/\`agent\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · required name string required type string required bearer | basic | api\_key | custom header\_name string Required for \`api\_key\`/\`custom\` (defaults to \`Authorization\`). secret string Token/key for bearer, api\_key, or custom types. username string Username for \`basic\` type (e.g. WordPress username for an Application Password). password string Password/application-password for \`basic\` type. Responses 200 Updated credential. ``` { "id": 3, "name": "HubSpot PAT", "type": "bearer", "header_name": "X-API-Key", "hint": "Bearer ****1234", "created_at": null } ``` 400 Validation error. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 409 Name already exists. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X PUT "https://your-site.com/wp-json/fswa/v1/credentials/id" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "name": "HubSpot PAT", "type": "bearer", "header_name": "X-API-Key", "secret": "sk_live_xxx", "username": "" }' ``` DELETE `/credentials/{id}` Delete a credential + Delete a credential Deletes a credential. Blocked with 409 if any webhook references it, unless \`force=true\` is passed (which detaches it from those webhooks first). Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | force | query | boolean | — | Detach from referencing webhooks and delete anyway. default: false | Responses 200 Deleted. ``` { "deleted": false, "id": 0, "detached": 0 } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 409 In use by one or more webhooks (pass force=true to override). ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X DELETE "https://your-site.com/wp-json/fswa/v1/credentials/id" \ -H "X-FSWA-Token: your-token" ``` GET `/credentials/key-status` Vault encryption-key status + Vault encryption-key status Reports whether the encryption key lives in the database or the \`FSWA\_SECRET\_KEY\` constant, and whether a re-encryption migration is needed. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Key status. ``` { "key_source": "database", "using_constant": false, "db_key_present": false, "fully_protected": false, "needs_migration": false, "total": 0 } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/credentials/key-status" \ -H "X-FSWA-Token: your-token" ``` POST `/credentials/reencrypt` Re-encrypt all credentials + Re-encrypt all credentials Re-wraps every credential with the current primary key. When \`FSWA\_SECRET\_KEY\` is in use and all succeed, the database key is deleted so only the wp-config key can decrypt. Requires \`full\`/\`agent\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Migration result. ``` { "migrated": 0, "failed": 0, "failed_ids": [], "db_key_removed": false } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/credentials/reencrypt" \ -H "X-FSWA-Token: your-token" ``` ## Dispatcher 4 endpoints Manually process the queue and manage cron configuration. Requires WordPress admin login. POST `/dispatcher/process` Manually process the queue + Manually process the queue Triggers a batch queue processing run immediately. \*\*Requires WordPress admin login.\*\* For automated cron use, use \`GET /cron/process\` instead. Auth Required WordPress admin session Request Body · optional batch\_size integer Responses 200 Processing result. ``` { "success": false, "message": null, "result": null } ``` 401 Not logged in as admin. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/dispatcher/process" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "batch_size": 0 }' ``` GET `/cron/process` Cron queue processing endpoint + Cron queue processing endpoint Processes a batch of due queue jobs. Authenticated via the \`token\` query parameter (a server-generated secret, not an API token). Designed for use with system cron: \`\`\` \*/1 \* \* \* \* curl -fsS 'https://your-site.com/wp-json/fswa/v1/cron/process?token=SECRET' >/dev/null 2>&1 \`\`\` The cron URL and command are available from \`GET /cron/info\`. Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | token | query | string | yes | Cron secret token from the plugin settings. | | batch\_size | query | integer | — | default: 10 | Responses 200 Processing result. ``` { "ok": false, "processed": 0, "succeeded": 0, "failed": 0 } ``` 403 Invalid cron token. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 500 Cron secret not configured. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/cron/process" \ -H "X-FSWA-Token: your-token" ``` GET `/cron/info` Get cron configuration + Get cron configuration Returns the cron secret token, the ready-to-use cron URL, and a crontab command for system cron setup. \*\*Requires WordPress admin login.\*\* Auth Required WordPress admin session Responses 200 Cron info. ``` { "token": null, "cron_url": null, "cron_command": "*/1 * * * * curl -fsS 'https://your-site.com/wp-json/fswa/v1/cron/process?token=...' >/dev/null 2>&1", "last_run": null, "last_run_human": "5 minutes ago", "wp_cron_disabled": false } ``` 401 Not logged in as admin. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/cron/info" \ -H "X-FSWA-Token: your-token" ``` POST `/cron/regenerate-token` Regenerate cron secret + Regenerate cron secret Generates a new cron secret, invalidating the previous one. \*\*Requires WordPress admin login.\*\* Auth Required WordPress admin session Responses 200 New cron token. ``` { "success": false, "token": null, "cron_url": null } ``` 401 Not logged in as admin. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/cron/regenerate-token" \ -H "X-FSWA-Token: your-token" ``` ## Code Glue 8 endpoints PHP snippet enrichment for webhook payloads — free since 3.0.0. Snippets run before dispatch (pre) to modify the payload, or after a successful delivery (post) for side-effects. Requires WordPress admin session or a \`full\`-scope API token. GET `/pro/snippets` List snippets + List snippets Returns all Code Glue snippets, optionally filtered by search term or tags. Requires \`read\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | search | query | string | — | Filter by name or code content (case-insensitive substring match). | | tags\[\] | query | array | — | Filter to snippets that have at least one of the given tags. | Responses 200 Array of snippets. 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/pro/snippets" \ -H "X-FSWA-Token: your-token" ``` POST `/pro/snippets` Create a snippet + Create a snippet Creates a new Code Glue snippet. Requires \`full\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · required name string required tags array code string required PHP snippet body. No \`target edges via /chains/{id}/links. Auth Required ApiTokenAuth Request Body · required name string required description string Responses 200 Created chain. ``` { "id": 0, "name": null, "description": null, "created_at": null, "updated_at": null, "member_webhook_ids": [] } ``` 409 A chain with that name already exists. example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/chains" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "name": "", "description": "" }' ``` GET `/chains/{id}` Get a chain + Get a chain Auth Required ApiTokenAuth Responses 200 Chain with its links. ``` { "id": 0, "name": null, "description": null, "created_at": null, "updated_at": null, "member_webhook_ids": [] } ``` 404 Chain not found. example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/chains/id" \ -H "X-FSWA-Token: your-token" ``` PATCH `/chains/{id}` Rename or re-describe a chain + Rename or re-describe a chain Auth Required ApiTokenAuth Request Body · required name string description string Responses 200 Updated chain. ``` { "id": 0, "name": null, "description": null, "created_at": null, "updated_at": null, "member_webhook_ids": [] } ``` 409 A chain with that name already exists. example request ``` curl -X PATCH "https://your-site.com/wp-json/fswa/v1/chains/id" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "name": "", "description": "" }' ``` DELETE `/chains/{id}` Delete a chain + Delete a chain Removes the chain and all its links. Member webhooks remain but lose their chain-link triggers; webhooks left triggerless render as orphans in the admin. Auth Required ApiTokenAuth Responses 200 Deleted. ``` { "deleted": false, "id": 0, "links_removed": 0 } ``` example request ``` curl -X DELETE "https://your-site.com/wp-json/fswa/v1/chains/id" \ -H "X-FSWA-Token: your-token" ``` GET `/chains/{id}/links` List chain links + List chain links Auth Required ApiTokenAuth Responses 200 Array of chain links. example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/chains/id/links" \ -H "X-FSWA-Token: your-token" ``` POST `/chains/{id}/links` Add a source->target link + Add a source->target link Wire a webhook so it fires when another webhook succeeds. Save-time cycle detection rejects edges that would create a cycle across any chain (HTTP 409 rest\_chain\_cycle). Auth Required ApiTokenAuth Request Body · required source\_webhook\_id integer required target\_webhook\_id integer required Responses 200 Created link. ``` { "id": 0, "chain_id": 0, "source_webhook_id": 0, "target_webhook_id": 0, "created_at": null } ``` 409 Would create a cycle, or link already exists. example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/chains/id/links" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "source_webhook_id": 0, "target_webhook_id": 0 }' ``` DELETE `/chains/{id}/links/{linkId}` Delete a link + Delete a link Removes the link and its synthetic fswa\_chain\_link:N trigger row. If this was the chain's last link, the chain itself is auto-deleted (chain\_deleted: true). Auth Required ApiTokenAuth Responses 200 Deleted. ``` { "deleted": false, "id": 0, "chain_deleted": false } ``` example request ``` curl -X DELETE "https://your-site.com/wp-json/fswa/v1/chains/id/links/linkId" \ -H "X-FSWA-Token: your-token" ``` ## Import / Export 3 endpoints Portable JSON export/import of webhooks and chains — with triggers, field mapping, conditions, and Code Glue. POST `/export` Export webhooks and chains as a portable build + Export webhooks and chains as a portable build Downloads a portable JSON build document containing the selected webhooks and/or chains — with their triggers, field mapping, conditions, and Code Glue snippets. Referenced auth credentials are exported as write-only references (masked hint only, never the secret). Requires \`operational\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · optional webhook\_ids array IDs of webhooks to export. chain\_ids array IDs of chains to export (member webhooks are included automatically). all boolean Export every webhook and chain; overrides the id lists. Responses 200 The portable build document. ``` { "schema_version": "1.0", "credentials": [], "webhooks": [], "chains": [] } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/export" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "webhook_ids": "", "chain_ids": "", "all": false }' ``` POST `/import/analyze` Analyze a build before import + Analyze a build before import Validates a build document and reports what to resolve before import: auth credentials that need mapping to existing vault entries (or provisioning) and name collisions with existing webhooks/chains. Creates nothing. Requires \`full\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · required document object required A build document previously produced by POST /export. object A build document previously produced by POST /export. Responses 200 Analysis result. ``` { "credentials_needed": [], "collisions": [], "counts": null } ``` 400 Missing or invalid import document. example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/import/analyze" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "document": "" }' ``` POST `/import` Import webhooks and chains from a build + Import webhooks and chains from a build Creates webhooks and chains from a build document. The document is validated against a strict schema (unknown keys rejected, types/enums enforced, referential integrity checked); a malformed document fails without partial application. Map exported credential references to existing vault credential IDs via \`credential\_map\`. Requires \`full\` scope or admin session. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Request Body · required document object required A build document produced by POST /export. object A build document produced by POST /export. credential\_map object Map of exported credential ref → existing vault credential ID. object Map of exported credential ref → existing vault credential ID. options object Import options (e.g. collision handling). object Import options (e.g. collision handling). Responses 200 Import result. ``` { "webhooks": 0, "chains": 0, "links": 0, "skipped": 0, "webhook_items": [], "chain_items": [] } ``` 400 Missing or invalid import document. example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/import" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "document": "", "credential_map": "", "options": "" }' ``` ## License Pro 3 endpoints Manage the Pro license key. Requires WordPress admin session. GET Pro `/license` Get license status + Get license status Auth Required WordPress admin session Responses 200 Current license state. ``` { "active": false, "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/license" \ -H "X-FSWA-Token: your-token" ``` POST Pro `/license/activate` Activate a license key + Activate a license key Auth Required WordPress admin session Request Body · required license\_key string required Responses 200 License activated. ``` { "active": true, "data": null } ``` 422 Invalid or limit-reached license key. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/license/activate" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "license_key": "XXXXX-XXXXX-XXXXX-XXXXX" }' ``` DELETE Pro `/license/deactivate` Deactivate the license + Deactivate the license Removes the local activation and frees the site slot on the license server. Auth Required WordPress admin session Responses 200 License deactivated. ``` { "active": false } ``` example request ``` curl -X DELETE "https://your-site.com/wp-json/fswa/v1/license/deactivate" \ -H "X-FSWA-Token: your-token" ``` ## Retry Settings 2 endpoints Site-wide retry defaults every webhook inherits unless it overrides them: global retry limit and backoff strategy. Free since 3.0.0; the \`pro/\` route prefix is kept for compatibility. Requires WordPress admin session. GET `/pro/settings` Get site-wide retry defaults + Get site-wide retry defaults Auth Required WordPress admin session Responses 200 Current site-wide retry and backoff defaults. ``` { "global_max_attempts": 5, "backoff_strategy": "exponential", "backoff_base_delay": 60, "backoff_max_delay": 3600 } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/pro/settings" \ -H "X-FSWA-Token: your-token" ``` PUT `/pro/settings` Update site-wide retry defaults + Update site-wide retry defaults All fields are optional. Pass \`null\` to clear a field back to its default (unlimited/disabled). Auth Required WordPress admin session Request Body · optional global\_max\_attempts integer Override the per-webhook max retry attempts for all webhooks. Null = use per-webhook setting. backoff\_strategy string exponential | linear | fixed Retry backoff algorithm. Null = use plugin default. backoff\_base\_delay integer Base delay in seconds for the backoff calculation. Null = use plugin default. backoff\_max\_delay integer Maximum delay cap in seconds between retries. Null = use plugin default. Responses 200 Updated fields (only fields that were passed are returned). ``` { "global_max_attempts": 5, "backoff_strategy": "exponential", "backoff_base_delay": 60, "backoff_max_delay": 3600 } ``` 400 Validation error. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X PUT "https://your-site.com/wp-json/fswa/v1/pro/settings" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "global_max_attempts": 5, "backoff_strategy": "exponential", "backoff_base_delay": 60, "backoff_max_delay": 3600 }' ``` ## External Cron Pro 5 endpoints Configure Uptime Kuma-based external cron (externalcron.com) to replace unreliable WP-Cron. The monitor pings either the FSWA queue processor or wp-cron.php on a fixed interval. Requires an active Pro license and WordPress admin session. GET Pro `/pro/external-cron/settings` Get External Cron settings + Get External Cron settings Auth Required WordPress admin session Responses 200 Current External Cron configuration. ``` { "enabled": false, "mode": "plugin_endpoint", "min_interval": 0, "interval": 60, "batch_size": 10, "monitor_id": 0 } ``` 403 No active Pro license. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/pro/external-cron/settings" \ -H "X-FSWA-Token: your-token" ``` PUT Pro `/pro/external-cron/settings` Update External Cron settings + Update External Cron settings All fields are optional. Changing \`mode\` updates the Kuma monitor URL. Changing \`interval\` or \`batch\_size\` updates the Kuma monitor configuration. Auth Required WordPress admin session Request Body · optional enabled boolean mode string plugin\_endpoint | wp\_cron interval integer Seconds between pings. Min 20 (plugin\_endpoint) or 60 (wp\_cron), max 3600. batch\_size integer Jobs processed per ping (1-100). Only relevant in plugin\_endpoint mode. Responses 200 Updated configuration. ``` { "enabled": false, "mode": "plugin_endpoint", "min_interval": 0, "interval": 60, "batch_size": 10, "monitor_id": 0 } ``` 400 Validation error (invalid mode, out-of-range interval or batch\_size). ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 No active Pro license. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X PUT "https://your-site.com/wp-json/fswa/v1/pro/external-cron/settings" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{ "enabled": false, "mode": "plugin_endpoint", "interval": 0, "batch_size": 0 }' ``` GET Pro `/pro/external-cron/stats` Get External Cron monitor stats + Get External Cron monitor stats Returns recent heartbeat history and uptime statistics from the Uptime Kuma monitor. Auth Required WordPress admin session Responses 200 Monitor statistics. ``` { "beats": [], "uptime_24h": null, "avg_ping": null } ``` 403 No active Pro license. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/pro/external-cron/stats" \ -H "X-FSWA-Token: your-token" ``` POST Pro `/pro/external-cron/pause` Pause the External Cron monitor + Pause the External Cron monitor Auth Required WordPress admin session Responses 200 Monitor paused. ``` { "paused": true } ``` 403 No active Pro license. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/pro/external-cron/pause" \ -H "X-FSWA-Token: your-token" ``` POST Pro `/pro/external-cron/resume` Resume the External Cron monitor + Resume the External Cron monitor Auth Required WordPress admin session Responses 200 Monitor resumed. ``` { "active": true } ``` 403 No active Pro license. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/pro/external-cron/resume" \ -H "X-FSWA-Token: your-token" ``` ## Activity History 3 endpoints Persistent audit log of every admin and API-token action: webhooks, tokens, settings, logs, queue, schemas, chains, cron, and Code Glue snippets. Each entry captures the actor, action type, affected object, structured old/new diffs, and optional AI prompt/reasoning from \`X-FSWA-Prompt\` / \`X-FSWA-Reason\` request headers. Requires \`read\` scope for listing; \`full\` scope for bulk delete. GET `/activity` List activity log entries + List activity log entries Returns paginated activity log entries, newest first. Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | page | query | integer | — | default: 1 | | per\_page | query | integer | — | default: 20 | | action | query | string | — | Filter by exact action string (e.g. \`webhook.updated\`). | | action\_prefix | query | string | — | Filter by action prefix (e.g. \`webhook\` matches all webhook actions). | | user\_id | query | integer | — | | | object\_type | query | string | — | | | object\_id | query | integer | — | | | date\_from | query | string | — | Filter from this UTC datetime (format: \`YYYY-MM-DD HH:MM:SS\`). | | date\_to | query | string | — | Filter until this UTC datetime (format: \`YYYY-MM-DD HH:MM:SS\`). | Responses 200 Array of activity log entries. 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/activity" \ -H "X-FSWA-Token: your-token" ``` DELETE `/activity` Bulk delete activity log entries + Bulk delete activity log entries Deletes activity log entries older than the specified number of days. Omitting \`older\_than\_days\` deletes all entries. Requires \`full\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | older\_than\_days | query | integer | — | Delete entries older than this many days. Omit to delete all entries. | Responses 200 Number of deleted records. ``` { "deleted": 0 } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X DELETE "https://your-site.com/wp-json/fswa/v1/activity" \ -H "X-FSWA-Token: your-token" ``` GET `/activity/{id}` Get an activity log entry + Get an activity log entry Returns a single activity log entry by ID. Requires \`read\` scope. Auth Required X-FSWA-Token headerAuthorization: Bearer?api\_token= Responses 200 Activity log entry. ``` { "id": 0, "user_id": 0, "token_id": 0, "token_hint": "ab12", "action": "webhook.updated", "object_type": "webhook" } ``` 401 Unauthorized — missing or invalid credentials. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 403 Forbidden — token does not have sufficient scope. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` 404 Not found. ``` { "code": "rest_webhook_not_found", "message": "Webhook not found.", "data": null } ``` example request ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/activity/id" \ -H "X-FSWA-Token: your-token" ``` /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"REST API Reference — Webhook Actions for WordPress","description":"OpenAPI reference for the Webhook Actions WordPress plugin. 77 endpoints across 16 resource groups.","url":"https://wpwebhooks.org/webhook-wordpress-plugin-api/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"}} ``` --- --- title: "WordPress Webhooks: Setup, Examples, and Why They Fail" description: "How WordPress webhooks work, why they fail silently in production, and what a reliable setup looks like — with Gravity Forms and WooCommerce examples." url: "https://wpwebhooks.org/wordpress-webhooks/" date: "2026-04-09" --- [WP Webhooks](https://wpwebhooks.org/) / WordPress Webhooks Guide / Complete Guide — Setup, Examples & Reliability # WordPress Webhooks: **Setup, Examples,** and Why They Fail Everything you need to understand WordPress webhooks — how they work under the hood, how to set them up, where they break in production, and what a reliable delivery setup actually looks like. ~15 min read Updated Apr 2026 #wordpress#webhooks#woocommerce#gravity-forms / Fundamentals ## How WordPress webhooks **actually work** A WordPress webhook is an outbound HTTP POST request sent automatically when something happens inside WordPress. A form gets submitted. An order status changes. A new user registers. WordPress fires an action hook, your code catches it, and sends a JSON payload to a URL you configured. That's the happy path. In practice, the implementation details determine whether your webhook is reliable or a ticking time bomb. Most webhook setups in WordPress rely on a single WordPress function called synchronously during the PHP request that triggered the event: typical wordpress webhook call ``` add_action( 'woocommerce_order_status_completed', function( $order_id ) { wp_remote_post( 'https://your-crm.example.com/webhook', [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'order_id' => $order_id ] ), 'timeout' => 5, ] ); // Result never checked. No retry. No log. If it fails, no one knows. } ); ``` This runs **inside the same PHP process** that completed the order. The HTTP request is sent synchronously — the PHP thread sits and waits for a response before doing anything else. The user's browser is waiting too. If the CRM is slow, the checkout is slow. If the CRM is down, the request times out after 5 seconds and the event is permanently lost. There is no queue. There is no retry. WordPress has already moved on. The key insight: **WordPress was built to render pages, not to deliver events reliably.** Using it as an event delivery system without adding the right infrastructure is the source of most webhook failures. / Setup ## **Setting up** webhooks in WordPress There is no single "WordPress webhooks" feature. How you set one up depends on where the event comes from. The general flow is always the same: - → **Identify the trigger event** — a form submission, order update, user action - → **Hook into the event** — via `add_action()` or a plugin's built-in webhook feature - → **Define the endpoint URL** — the external system that will receive the payload - → **Shape the payload** — decide what data to send and in what format - → **Send the HTTP request** — using `wp_remote_post()` or a plugin Plugins like Gravity Forms, Contact Form 7, and WooCommerce all have their own webhook/notification systems with varying levels of configurability. Custom plugins and themes typically use `add_action()` hooks directly. Gravity Forms → Webhook ### Gravity Forms Gravity Forms has a built-in Webhooks Add-On. Configure endpoint URL, request format, and field mapping per-form. No custom code needed for basic setups. [Full setup guide + reliability fix →](https://wpwebhooks.org/examples/gravity-forms-webhooks/) Contact Form 7 → Webhook ### Contact Form 7 CF7 requires a plugin or custom code to send webhooks. The CF7 to Webhook plugin is the common solution — it adds endpoint configuration to each form. [Full CF7 webhook example →](https://wpwebhooks.org/examples/cf7-to-webhook/) WooCommerce → Webhook ### WooCommerce WooCommerce has a native webhook system under WooCommerce → Settings → Advanced → Webhooks. It covers order, product, customer, and coupon events. Delivery is handled via Action Scheduler, so it is asynchronous with basic retry support — but logging and replay are limited compared to a dedicated delivery layer. [WooCommerce order webhook example →](https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/) / Use cases ## Common use cases for WordPress webhooks Most WordPress webhook use cases fall into one of these four categories. Notice that all of them are **business-critical flows** — which makes silent failures a serious problem. 01 / LEADS ### Send leads to CRM Form submissions — Gravity Forms, CF7, WPForms — pushed to HubSpot, Pipedrive, or a custom API. A missed event means a missed lead. No second chance. 02 / AUTOMATION ### Trigger automation workflows WordPress as an event source for n8n, Make, Zapier, or Pipedream. Order placed → Slack notification → fulfillment ticket created. One dropped webhook breaks the whole chain. 03 / ORDERS ### Sync WooCommerce orders Push order events to ERP systems, 3PL providers, accounting platforms. Order fulfillment depends on the event arriving. A missed delivery means a missed shipment. 04 / INTEGRATIONS ### Custom API integrations Push WordPress events to Notion, Airtable, Slack, custom SaaS APIs. Any HTTP endpoint can be a webhook consumer — making WordPress a flexible event emitter for your entire stack. If any of these flows matter to your business, you cannot afford fire-and-forget delivery. The question is not _if_ a webhook will fail — it's _when_, and what happens next. / The problem ## Why WordPress webhooks **fail silently** in production Most WordPress webhook setups are "fire and forget" — which really means "hope it works." When conditions are perfect, they do. In production, conditions are never perfect. - ✕ **No retry mechanism** — a single failed request means the event is gone permanently - ✕ **Synchronous execution** — the HTTP request blocks the PHP process; slow endpoints slow down your site - ✕ **Timeouts** — default timeouts are 5–10 seconds; any API under load will exceed this - ✕ **No persistent queue** — events are not stored before dispatch; a PHP crash loses the event entirely - ✕ **No logging** — WordPress does not log outbound HTTP requests by default; failures are invisible - ✕ **External API downtime** — maintenance windows, rate limits, and outages are normal; your webhook will hit them - ✕ **No replay** — even if you notice a failure, you cannot resend the original payload **The silence is the failure mode.** No error in the WordPress admin. No email. No alert. The order shows as completed, the form shows as submitted — but your CRM, ERP, or automation platform never received the event. These are not edge cases. They are the normal operating conditions of any production environment. External APIs have planned maintenance. Networks drop packets. Rate limits kick in. A webhook system that can't handle any of these is not production-ready. / Consequences ## What actually happens **when a webhook fails** The downstream effects of a missed webhook depend entirely on what the webhook was doing. They range from annoying to genuinely damaging. LEADS ### Lost leads A contact form submission that never reaches your CRM is a prospect who falls through the cracks. Sales teams never see them. Follow-ups never happen. ORDERS ### Missed order events A WooCommerce "order completed" event that never reaches your ERP means an order that never gets fulfilled, or an invoice that never gets created. AUTOMATION ### Broken automation chains Automation workflows in n8n, Make, or Zapier are only as reliable as the trigger event. One dropped webhook breaks the entire downstream sequence silently. VISIBILITY ### Zero visibility The hardest part: you often won't know something went wrong until a customer complains or you manually audit records. By then, recovery is difficult. The consistent pattern across all these failures: the problem is discovered _days later_, when someone notices a discrepancy. By then, PHP logs have rotated, the original payload is gone, and there is no forensic record to work with. / Architecture ## Synchronous vs **asynchronous** webhooks The core architectural decision in webhook delivery is when the HTTP request gets sent: **during the PHP request that triggered the event** (synchronous), or **in a background process** (asynchronous). Synchronous — inline User submits form ↓ WordPress fires action hook ↓ wp\_remote\_post() called ↓ PHP thread waits for response ↓ API slow → user waits too ↓ API down → request fails, event lost Asynchronous — queued User submits form ↓ WordPress fires action hook ↓ Event stored in queue (fast, safe) ↓ Response returned to user immediately ↓ Background worker dispatches event ↓ Failure? Auto-retry with backoff Async delivery gives you two things that synchronous delivery can never provide: - → **Reliability** — the event is stored before dispatch, so it survives any failure in the delivery path - → **Performance** — the user-facing request is never blocked by an outbound HTTP call to a slow external API The tradeoff is latency — async delivery introduces a small delay between event and delivery (typically seconds to a minute). For most use cases, this is completely acceptable. For use cases where it isn't, synchronous delivery can still be used selectively while still adding logging and idempotency. For a detailed walkthrough of async dispatch architecture in WordPress, see [Async Webhooks in WordPress →](https://wpwebhooks.org/blog/async-webhooks-wordpress/) / The fix ## What a **reliable webhook setup** looks like Reliable webhook delivery is not about writing better PHP. It is about adding the right infrastructure around the HTTP call. A production-grade setup needs five components: 01 / QUEUE ### Persistent queue Store events in the database before attempting delivery. This decouples event capture from dispatch — the event survives even if the delivery process crashes mid-flight. 02 / RETRY ### Automatic retry with backoff Retry failed deliveries automatically. Space retries further apart with each attempt: 1 min → 2 min → 4 min → 8 min. Retry 5xx and network errors; fail permanently on 4xx — those are configuration problems, not transient failures. 03 / ASYNC ### Background dispatch Send events from a background worker process, not during the user-facing request. This removes the performance risk and allows the worker to handle long-running deliveries safely. 04 / LOGS ### Delivery logs Log every attempt: timestamp, endpoint, HTTP status, response body, duration. Logs are what turn invisible failures into diagnosable problems — and they're the foundation for replay. 05 / REPLAY ### Replay capability After an outage or configuration problem, you need to be able to resend failed events from the delivery log. Without replay, any failure window means permanent data loss. These five components are not optional extras. Each one addresses a specific failure mode. Remove any one of them and you reintroduce that failure mode. [Deep dive: building a retry and replay system for WordPress →](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/) / Limitations ## WordPress limitations **and workarounds** WordPress was not designed as event infrastructure, and that shows in several ways. Understanding the limitations helps you work around them effectively. ### WP-Cron is not a real scheduler WP-Cron — WordPress's built-in job scheduler — does not run on a time-based schedule. It fires on page load. If your site receives no traffic overnight, WP-Cron does not run. Events that should have been retried at 2am sit in the queue until morning. The reliable fix: disable WP-Cron's page-load trigger and replace it with a real system cron job that runs every minute. [Full breakdown of WP-Cron limitations and fixes →](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/) ### PHP execution model PHP is stateless and request-bound. Each page load boots PHP fresh, runs, and terminates. There is no persistent background process. This means background jobs must be triggered externally — you cannot have a long-running PHP worker without additional infrastructure. On shared hosting, execution time limits (often 30–60 seconds) can kill a batch processing job mid-run, leaving some events in a "processing" limbo state. Your queue needs a stuck-job detection mechanism to recover from this. ### No built-in queue system WordPress core has no persistent job queue. The common solutions are: - → **Custom database table** — full control, requires schema creation and maintenance - → **Action Scheduler** — an open-source library (included with WooCommerce) that provides a persistent job queue with retry logic and a UI - → **WordPress Webhook Plugin** — purpose-built delivery layer with queue, retry, logs, and replay out of the box Action Scheduler is a solid general-purpose queue, but it requires custom integration code to handle all the delivery concerns (status tracking, per-attempt logging, idempotency keys). A purpose-built solution handles these for you. / Comparison ## Fire-and-forget vs **production-grade** delivery Each row in this table represents a specific failure mode. The fire-and-forget column is missing all the things that let you detect, recover from, and prevent delivery failures. | Capability | Fire-and-forget | Production-grade | | --- | --- | --- | | Execution model | Synchronous, blocks PHP | Background worker | | Event persistence | None — lost on crash | Database queue before dispatch | | Retry on failure | Never | Automatic with exponential backoff | | Retry logic | N/A | 5xx + network errors only; 4xx → permanent fail | | Delivery logging | None | Per-attempt: status, body, duration | | Event replay | Not possible | Manual resend from delivery log | | Idempotency | None | UUID on every event, stable across retries | | Performance impact | Blocks user-facing request | Zero — async dispatch | | Failure visibility | Silent | Logged, queryable, alertable | / Debugging ## Debugging WordPress webhook **delivery issues** Debugging a fire-and-forget webhook implementation is painful: there is nothing to debug with. A production-grade setup makes debugging straightforward. ### Start with delivery logs If you have per-attempt logs, debugging is usually a five-second task: find the event, look at the HTTP status and response body, and you know exactly what went wrong. A 401 means authentication. A 400 means payload format. A 503 means the API was down. ### Inspect the payload The most common source of 400 errors is a payload structure mismatch — the receiving API expects a different field name, nesting, or data type. Log the full request body on every attempt. When something breaks, you can inspect exactly what was sent. ### Test your endpoint independently Before debugging the WordPress side, verify the endpoint works. Use [Hoppscotch](https://hoppscotch.io) or curl to send a test payload directly to the endpoint URL and confirm it responds correctly. This isolates whether the problem is WordPress-side or endpoint-side. ### Retry manually If your setup supports it, manually replay the failed event after fixing the underlying cause. This is the only way to recover events without asking the user to resubmit a form or re-trigger an order event. ### Use webhook.site during development [webhook.site](https://webhook.site) gives you a temporary URL that logs every incoming request in full detail — headers, body, timing. Invaluable for inspecting exactly what WordPress is sending before you point it at a real endpoint. / Best practices ## Best practices for WordPress webhooks Most of these follow directly from the failure modes described above. They're not optional for anything you run in production. - → **Always log every request** — HTTP status, response body, and duration on every attempt, success or failure - → **Never rely on a single delivery attempt** — assume the first attempt will sometimes fail - → **Use exponential backoff for retries** — immediate retries overwhelm already-struggling endpoints - → **Distinguish retryable from permanent failures** — 5xx is retryable; 4xx is a configuration problem that needs human attention - → **Store a unique event ID before dispatch** — the same ID travels with every retry; receivers use it to deduplicate - → **Validate the response, not just the status code** — some endpoints return 200 with an error in the body - → **Keep payloads versioned** — add a version field so receivers can handle schema evolution gracefully - → **Monitor queue depth** — a growing backlog is an early warning sign of endpoint problems - → **Set up alerts on permanent failures** — silent dead-letter queues are the worst of all outcomes The single highest-leverage change you can make: **add logging before anything else**. You cannot fix what you cannot see. Even if you don't implement the full queue-and-retry system immediately, logging every delivery attempt gives you the visibility to understand where and when things are failing. / Examples ## Real examples: **step-by-step guides** These guides walk through complete webhook setups — including the reliability layer. Not just "how to configure the webhook URL," but how to handle failures, log deliveries, and recover from outages. - [→ Gravity Forms webhook setup — endpoint configuration, field mapping, retry and delivery log](https://wpwebhooks.org/examples/gravity-forms-webhooks/) - [→ Contact Form 7 webhook guide — plugin setup, payload format, making it failure-proof](https://wpwebhooks.org/examples/cf7-to-webhook/) - [→ All WordPress webhook integration examples →](https://wpwebhooks.org/examples/) - [→ Async webhooks in WordPress — moving dispatch out of the PHP request cycle](https://wpwebhooks.org/blog/async-webhooks-wordpress/) - [→ WordPress webhook retry and replay system — queue, backoff, and observability](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/) - [→ Why WP-Cron is not enough for async webhooks — scheduling gaps and fixes](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/) /FAQ ## Common questions answered. Have more questions? Browse the full docs or check the API reference. What are WordPress webhooks? + WordPress webhooks are outbound HTTP POST requests triggered automatically when specific events occur inside WordPress — a form submission, an order status change, a new user registration. They send a JSON payload to a URL you configure, allowing WordPress to push data to external systems in near real time. Why do WordPress webhooks fail silently? + The default WordPress webhook implementation uses wp\_remote\_post() called synchronously during page execution. There is no retry mechanism, no persistent queue, and no logging. If the receiving API is unavailable, the request fails and the event disappears permanently with no record that anything went wrong. What is the difference between synchronous and asynchronous webhooks in WordPress? + Synchronous webhooks send the HTTP request inline during the PHP execution that triggered the event. If the request is slow or fails, the user's page is affected and the event may be lost. Asynchronous webhooks store the event in a queue and dispatch it in a background worker process. This decouples delivery from the user request, enables retries, and provides full delivery logging. How do I make WordPress webhooks reliable? + Reliable delivery requires: a persistent queue (store events before dispatch), automatic retry with exponential backoff (for 5xx and network errors), delivery logging (every attempt recorded with status and response), replay capability (resend failed events manually), and idempotency keys (unique event UUIDs for safe deduplication by receivers). The WordPress Webhook Plugin implements all of these out of the box. Does WordPress have built-in webhook support? + WordPress core has no built-in webhook system. WooCommerce ships with webhook support for order, product, customer, and coupon events — delivered via Action Scheduler with basic retry. Gravity Forms and Contact Form 7 require either a dedicated add-on or plugin. For full delivery logging, replay capability, and idempotency across all event types, you need either a dedicated webhook plugin or custom code implementing the complete delivery layer. / Related - [→ Gravity Forms webhook example — complete setup with delivery logs and retry](https://wpwebhooks.org/examples/gravity-forms-webhooks/) - [→ Contact Form 7 webhook guide — making CF7 integrations failure-proof](https://wpwebhooks.org/examples/cf7-to-webhook/) - [→ Why WordPress webhooks silently fail in production — every failure mode mapped](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) - [→ Async webhooks in WordPress — architecture and implementation](https://wpwebhooks.org/blog/async-webhooks-wordpress/) - [→ Why WP-Cron is not enough — the scheduling gap that causes silent failures](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/) - [→ WordPress webhook retry and replay system — queue design and backoff strategy](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Webhooks: Setup, Examples, and Why They Fail","description":"How WordPress webhooks work, why they fail silently in production, and what a reliable setup looks like — with Gravity Forms and WooCommerce examples.","datePublished":"2026-04-09","dateModified":"2026-04-09","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org","sameAs":["https://flowsystems.pl"]},"url":"https://wpwebhooks.org/wordpress-webhooks/","image":"https://wpwebhooks.org/wordpress-webhooks/og_image.jpg","keywords":["wordpress webhooks","wordpress webhook setup","gravity forms webhook","contact form 7 webhook","woocommerce webhooks","reliable webhooks wordpress","webhook retry","async webhooks wordpress"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"WordPress Webhooks Guide","item":"https://wpwebhooks.org/wordpress-webhooks/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What are WordPress webhooks?","acceptedAnswer":{"@type":"Answer","text":"WordPress webhooks are outbound HTTP POST requests triggered automatically when specific events occur inside WordPress — a form submission, an order status change, a new user registration. They send a JSON payload to a URL you configure, allowing WordPress to push data to external systems in near real time."}},{"@type":"Question","name":"Why do WordPress webhooks fail silently?","acceptedAnswer":{"@type":"Answer","text":"The default WordPress webhook implementation uses wp_remote_post() called synchronously during page execution. There is no retry mechanism, no persistent queue, and no logging. If the receiving API is unavailable, the request fails and the event disappears permanently with no record that anything went wrong."}},{"@type":"Question","name":"What is the difference between synchronous and asynchronous webhooks in WordPress?","acceptedAnswer":{"@type":"Answer","text":"Synchronous webhooks send the HTTP request inline during the PHP execution that triggered the event. If the request is slow or fails, the user's page is affected and the event may be lost. Asynchronous webhooks store the event in a queue and dispatch it in a background worker process. This decouples delivery from the user request, enables retries, and provides full delivery logging."}},{"@type":"Question","name":"How do I make WordPress webhooks reliable?","acceptedAnswer":{"@type":"Answer","text":"Reliable delivery requires: a persistent queue (store events before dispatch), automatic retry with exponential backoff (for 5xx and network errors), delivery logging (every attempt recorded with status and response), replay capability (resend failed events manually), and idempotency keys (unique event UUIDs for safe deduplication by receivers). The WordPress Webhook Plugin implements all of these out of the box."}},{"@type":"Question","name":"Does WordPress have built-in webhook support?","acceptedAnswer":{"@type":"Answer","text":"WordPress core has no built-in webhook system. WooCommerce ships with webhook support for order, product, customer, and coupon events — delivered via Action Scheduler with basic retry. Gravity Forms and Contact Form 7 require either a dedicated add-on or plugin. For full delivery logging, replay capability, and idempotency across all event types, you need either a dedicated webhook plugin or custom code implementing the complete delivery layer."}}]} ``` --- --- title: "Pricing — Webhook Actions Pro" description: "Pro adds two things: hosted AI credits so Build with AI needs no API key, and External Cron for reliable queue delivery. Everything else is free. Starter from $249/yr." url: "https://wpwebhooks.org/pricing/" --- ![A vast synthetic face of pale ceramic inlaid with glowing green circuit tracery, growing out of a mass of thick black cabling in a dark server hall, its lit eyes fixed on the viewer.](https://wpwebhooks.org/pricing/ai-hero-1920.webp) / WP Webhooks AI # A synthetic developer who has already seen the payload and read API. It knows the shape of an event your site has never fired, every argument your site's own REST API requires, and what HubSpot, Airtable or Notion expect in the request today — not what a model remembers. What it cannot know is your keys — a form's fields, your post meta — so for those it asks you to fire the event once, and names the field it is waiting on. - Payload Library 574 captured hook payloads, across 19 sources - API Docs Library 312 APIs whose current reference is read into the build — an unknown one is researched on the spot - AI Credits A monthly pool that renews itself — no API key [See the plans →](#pricing) ![A colossal rusted delivery robot standing upright and lit in a dark industrial hall, its single optic throwing a green beam through the rain, fed by an armoured supply line running up and out through the ceiling.](https://wpwebhooks.org/pricing/cron-hero-1920.webp) / External Cron ## A machine that never waits for a visitor. WP-Cron only fires when somebody lands on your site. External Cron fires from outside it, on its own clock, whether anyone shows up or not. - Queue endpoint The webhook queue, every 20 s to 1 hour - Full WP-Cron endpoint Every scheduled task, every 60 s to 1 hour - No crontab Activates itself the moment a licence does [See the plans →](#pricing) Everything else — Code Glue, dynamic URLs, unlimited conditions, per-webhook retry and backoff, the queue, the logs, the REST API and MCP — is in the free plugin, on unlimited sites. /Pricing ## Everything in Pro. Pick your site count. All three plans unlock the same Pro feature set — Build with AI on included credits, and External Cron. Plans differ by the number of production sites — each one including its staging and dev copies — and the monthly AI credit allowance. /Open source Free The whole pipeline — every feature except hosted AI and External Cron. $0free forever [Download](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) → 55 free AI credits — no key, no signup → Build with AI — or bring your own key → Async queue + per-webhook retries & backoff → Code Glue — PHP before and after dispatch → Unlimited conditions with AND/OR groups → Dynamic URLs resolved from the payload → Full delivery log + one-click replay → REST API and MCP with scoped tokens → GPLv2 — unlimited installs /1 site Starter Hosted AI and External Cron for a single production site, staging and dev included. $249/yr Billed $249/yr — save $99 [Get Starter](https://api.wpwebhooks.org/checkout/starter?billing=yearly) → 5,000 AI credits/mo — build 200+ automations → Hosted AI — no API key to manage → Payload Library — 574 hooks it has already seen → API Docs Library — 312 APIs it reads before it builds → External Cron — reliable delivery without WP-Cron → 1 production site — staging & dev included → Unlimited local installs → Priority email support → 1 year of updates Most Popular /Most popular Business The right fit for growing teams and stores. $399/yr Billed $399/yr — save $141 [Get Business](https://api.wpwebhooks.org/checkout/business?billing=yearly) → 12,500 AI credits/mo — build 500+ automations → Hosted AI — no API key to manage → Payload Library — 574 hooks it has already seen → API Docs Library — 312 APIs it reads before it builds → External Cron — reliable delivery without WP-Cron → 10 production sites — staging & dev included → Unlimited local installs → Priority email support → 1 year of updates /75 sites Agency For agencies and platforms running at scale. $649/yr Billed $649/yr — save $179 [Get Agency](https://api.wpwebhooks.org/checkout/agency?billing=yearly) → 30,000 AI credits/mo — build 1,200+ automations → Hosted AI — no API key to manage → Payload Library — 574 hooks it has already seen → API Docs Library — 312 APIs it reads before it builds → External Cron — reliable delivery without WP-Cron → 75 production sites — staging & dev included → Unlimited local installs → Priority email support → 1 year of updates Prices exclude VAT. Any applicable tax is calculated and shown at checkout. /AI Credits ## Build with AI, included with every Pro plan. Build with AI runs on credits. The free plugin starts you with **55 credits — about two complete automations** — granted on your first prompt, with no key and no signup. Every Pro plan then includes a monthly pool that renews automatically: no API keys, no provider accounts, no per-token bill to reconcile. You're charged fairly by usage: a credit reflects the work a request actually does, so a quick question costs a little and a large multi-step build costs more. A typical request runs about 11 credits, and a full automation — planning, mapping fields, writing a Code Glue snippet, and test-firing it — usually lands around 25. / Starter 5,000 credits / month Enough for 200+ automations a month. / Business 12,500 credits / month Enough for 500+ automations a month. / Agency 30,000 credits / month Enough for 1,200+ automations a month. Prefer your own provider key? Build with AI still works with your own Anthropic, OpenAI, or Google key — or a WordPress AI connector — on every plan, including free. ✓ All plans are covered by a **14-day money-back guarantee**. Not satisfied for any reason? We'll refund you in full, provided no more than 5% of the plan's monthly AI credits have been used. See the [refund policy](https://wpwebhooks.org/refund-policy/). /FAQ ## Common questions about Pro. Still have questions? Email us at [support@wpwebhooks.org](mailto:support@wpwebhooks.org). What counts as a "site"? + One domain, with its environments. A site slot is counted per domain, so example.com, www.example.com and any environment copy on a subdomain of it — staging., stage., dev., develop., test., testing., qa., uat., sandbox. or preview. — all share a single slot. Run any or all of them and it still costs one site. Separate domains, and other subdomains such as shop.example.com, demo.example.com or beta.example.com, each take a slot of their own. Local environments — localhost, \*.local, \*.test, and private IP addresses — are always free and never count against your site limit. Can I use the plugin on staging and dev sites? + Yes, and they do not cost you an extra slot. Local environments — localhost, \*.local, \*.test, and private IP addresses — are detected automatically and never consume a license slot, so you can run unlimited local installs on any plan. A public staging or dev site shares the slot of the domain it belongs to whenever it sits on one of the recognised environment subdomains — staging., stage., dev., develop., test., testing., qa., uat., sandbox. or preview. — so example.com, staging.example.com and dev.example.com together use one site. It works the other way round too: a license used only on staging.example.com, with no production site activated yet, still counts as a single site. A staging copy kept on a separate domain, such as example-staging.com, is a different domain and does take its own slot. What do I actually get for paying? + Two things that run on our infrastructure rather than yours: hosted AI credits, so Build with AI works without you managing an API key, and External Cron, which drains the delivery queue reliably without depending on WP-Cron. Plus priority support. Everything else — Code Glue, dynamic URLs, unlimited conditions and condition groups, per-webhook retry and backoff settings, the queue, the logs, the REST API and MCP — is in the free plugin, on unlimited sites. Starter, Business and Agency differ only in the number of production sites allowed (each one including its staging and dev copies) and the size of the monthly AI credit pool. What happens if my license expires? + The plugin keeps working exactly as-is — no features are locked or disabled. You lose access to updates and priority support until you renew. All your data stays intact. Can I upgrade my plan later? + Yes. Contact us and we'll upgrade your plan. We apply fair prorated pricing. Can I downgrade my plan? + Downgrades are not supported during the billing period. You can switch to a lower plan at renewal. What is an AI credit, and how far does it go? + Credits are charged by usage — how much the agent actually processes on each request — so you never overpay for a quick question or a small build, and heavier builds simply cost a bit more. A typical request runs about 11 credits, and a complete integration — the agent planning it, reading your site's hooks and REST routes, creating the webhook, mapping the fields, writing a Code Glue snippet, and test-firing it — usually lands around 25 credits total, even for an advanced multi-step build. So 5,000 credits a month is around 200 integrations, far more than most sites ever need. The plugin shows your remaining balance while you work. What happens when I run out of AI credits? + Build with AI pauses and tells you, with a link to buy a top-up pack ($19 for 2,000 credits, $49 for 6,000). Top-up credits never expire and are only spent after each month's included pool. Your included credits reset automatically every month. Nothing else in the plugin is affected — webhooks, queue, retries, Code Glue and External Cron all keep running normally. Do I still need my own OpenAI or Anthropic API key? + No. Included credits run on our servers with our provider keys, so there is nothing to sign up for and no per-token bill. If you would rather use your own key — Anthropic, OpenAI, Google, or a WordPress AI connector — that option stays available on every plan, including the free plugin. You can switch back and forth at any time in the plugin settings. Is there a free trial? + Yes. The free plugin comes with 55 AI credits, granted automatically the first time you send a prompt to Build with AI — no key, no signup, no card. That is about five requests, or two complete automations: enough to watch the agent plan an integration, map the fields, and test-fire it on your own site. When the trial credits are gone, Build with AI keeps working on your own Anthropic, OpenAI or Google key, or you can move to Pro for a monthly pool of hosted credits. Every Pro purchase is also covered by a 14-day money-back guarantee — a full refund for any reason, provided no more than 5% of your plan's monthly AI credits have been used, since credits are spent on our servers at real cost. What payment methods are accepted? + We accept all major credit and debit cards via Stripe. Payments are processed securely — we never store card details. Do prices include VAT or tax? + No — prices shown exclude VAT. Any applicable VAT or sales tax is calculated based on your billing country and shown before you confirm payment at checkout. If you are a VAT-registered business in the EU, you can enter your VAT number at checkout. /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"Product","name":"Webhook Actions Pro","description":"Webhook Actions Pro adds hosted AI credits for Build with AI and External Cron for reliable queue delivery, on top of the free webhook pipeline.","url":"https://wpwebhooks.org/pricing/","image":"https://wpwebhooks.org/favicon.png","brand":{"@type":"Brand","name":"WP Webhooks"},"offers":[{"@type":"Offer","name":"Starter (Yearly)","price":"249","priceCurrency":"USD","priceSpecification":{"@type":"UnitPriceSpecification","billingDuration":"P1Y","valueAddedTaxIncluded":false},"availability":"https://schema.org/InStock","url":"https://api.wpwebhooks.org/checkout/starter?billing=yearly","hasMerchantReturnPolicy":{"@type":"MerchantReturnPolicy","applicableCountry":["US","GB","CA","AU","DE","FR","NL","PL","SE","NO","DK","FI","IT","ES","PT","BE","AT","CH","IE","NZ","JP","SG","IN","BR","MX"],"returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow","merchantReturnDays":14,"returnFees":"https://schema.org/FreeReturn","returnMethod":"https://schema.org/ReturnByMail"},"shippingDetails":{"@type":"OfferShippingDetails","shippingRate":{"@type":"MonetaryAmount","value":"0","currency":"USD"},"shippingDestination":{"@type":"DefinedRegion","addressCountry":"US"},"deliveryTime":{"@type":"ShippingDeliveryTime","handlingTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"},"transitTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"}}}},{"@type":"Offer","name":"Starter (Monthly)","price":"29","priceCurrency":"USD","priceSpecification":{"@type":"UnitPriceSpecification","billingDuration":"P1M","valueAddedTaxIncluded":false},"availability":"https://schema.org/InStock","url":"https://api.wpwebhooks.org/checkout/starter?billing=monthly","hasMerchantReturnPolicy":{"@type":"MerchantReturnPolicy","applicableCountry":["US","GB","CA","AU","DE","FR","NL","PL","SE","NO","DK","FI","IT","ES","PT","BE","AT","CH","IE","NZ","JP","SG","IN","BR","MX"],"returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow","merchantReturnDays":14,"returnFees":"https://schema.org/FreeReturn","returnMethod":"https://schema.org/ReturnByMail"},"shippingDetails":{"@type":"OfferShippingDetails","shippingRate":{"@type":"MonetaryAmount","value":"0","currency":"USD"},"shippingDestination":{"@type":"DefinedRegion","addressCountry":"US"},"deliveryTime":{"@type":"ShippingDeliveryTime","handlingTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"},"transitTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"}}}},{"@type":"Offer","name":"Business (Yearly)","price":"399","priceCurrency":"USD","priceSpecification":{"@type":"UnitPriceSpecification","billingDuration":"P1Y","valueAddedTaxIncluded":false},"availability":"https://schema.org/InStock","url":"https://api.wpwebhooks.org/checkout/business?billing=yearly","hasMerchantReturnPolicy":{"@type":"MerchantReturnPolicy","applicableCountry":["US","GB","CA","AU","DE","FR","NL","PL","SE","NO","DK","FI","IT","ES","PT","BE","AT","CH","IE","NZ","JP","SG","IN","BR","MX"],"returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow","merchantReturnDays":14,"returnFees":"https://schema.org/FreeReturn","returnMethod":"https://schema.org/ReturnByMail"},"shippingDetails":{"@type":"OfferShippingDetails","shippingRate":{"@type":"MonetaryAmount","value":"0","currency":"USD"},"shippingDestination":{"@type":"DefinedRegion","addressCountry":"US"},"deliveryTime":{"@type":"ShippingDeliveryTime","handlingTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"},"transitTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"}}}},{"@type":"Offer","name":"Business (Monthly)","price":"45","priceCurrency":"USD","priceSpecification":{"@type":"UnitPriceSpecification","billingDuration":"P1M","valueAddedTaxIncluded":false},"availability":"https://schema.org/InStock","url":"https://api.wpwebhooks.org/checkout/business?billing=monthly","hasMerchantReturnPolicy":{"@type":"MerchantReturnPolicy","applicableCountry":["US","GB","CA","AU","DE","FR","NL","PL","SE","NO","DK","FI","IT","ES","PT","BE","AT","CH","IE","NZ","JP","SG","IN","BR","MX"],"returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow","merchantReturnDays":14,"returnFees":"https://schema.org/FreeReturn","returnMethod":"https://schema.org/ReturnByMail"},"shippingDetails":{"@type":"OfferShippingDetails","shippingRate":{"@type":"MonetaryAmount","value":"0","currency":"USD"},"shippingDestination":{"@type":"DefinedRegion","addressCountry":"US"},"deliveryTime":{"@type":"ShippingDeliveryTime","handlingTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"},"transitTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"}}}},{"@type":"Offer","name":"Agency (Yearly)","price":"649","priceCurrency":"USD","priceSpecification":{"@type":"UnitPriceSpecification","billingDuration":"P1Y","valueAddedTaxIncluded":false},"availability":"https://schema.org/InStock","url":"https://api.wpwebhooks.org/checkout/agency?billing=yearly","hasMerchantReturnPolicy":{"@type":"MerchantReturnPolicy","applicableCountry":["US","GB","CA","AU","DE","FR","NL","PL","SE","NO","DK","FI","IT","ES","PT","BE","AT","CH","IE","NZ","JP","SG","IN","BR","MX"],"returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow","merchantReturnDays":14,"returnFees":"https://schema.org/FreeReturn","returnMethod":"https://schema.org/ReturnByMail"},"shippingDetails":{"@type":"OfferShippingDetails","shippingRate":{"@type":"MonetaryAmount","value":"0","currency":"USD"},"shippingDestination":{"@type":"DefinedRegion","addressCountry":"US"},"deliveryTime":{"@type":"ShippingDeliveryTime","handlingTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"},"transitTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"}}}},{"@type":"Offer","name":"Agency (Monthly)","price":"69","priceCurrency":"USD","priceSpecification":{"@type":"UnitPriceSpecification","billingDuration":"P1M","valueAddedTaxIncluded":false},"availability":"https://schema.org/InStock","url":"https://api.wpwebhooks.org/checkout/agency?billing=monthly","hasMerchantReturnPolicy":{"@type":"MerchantReturnPolicy","applicableCountry":["US","GB","CA","AU","DE","FR","NL","PL","SE","NO","DK","FI","IT","ES","PT","BE","AT","CH","IE","NZ","JP","SG","IN","BR","MX"],"returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow","merchantReturnDays":14,"returnFees":"https://schema.org/FreeReturn","returnMethod":"https://schema.org/ReturnByMail"},"shippingDetails":{"@type":"OfferShippingDetails","shippingRate":{"@type":"MonetaryAmount","value":"0","currency":"USD"},"shippingDestination":{"@type":"DefinedRegion","addressCountry":"US"},"deliveryTime":{"@type":"ShippingDeliveryTime","handlingTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"},"transitTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"}}}},{"@type":"Offer","name":"AI Credits — 2,000 pack","price":"19","priceCurrency":"USD","priceSpecification":{"@type":"UnitPriceSpecification","valueAddedTaxIncluded":false},"availability":"https://schema.org/InStock","url":"https://wpwebhooks.org/pricing/#credits","hasMerchantReturnPolicy":{"@type":"MerchantReturnPolicy","applicableCountry":["US","GB","CA","AU","DE","FR","NL","PL","SE","NO","DK","FI","IT","ES","PT","BE","AT","CH","IE","NZ","JP","SG","IN","BR","MX"],"returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow","merchantReturnDays":14,"returnFees":"https://schema.org/FreeReturn","returnMethod":"https://schema.org/ReturnByMail"},"shippingDetails":{"@type":"OfferShippingDetails","shippingRate":{"@type":"MonetaryAmount","value":"0","currency":"USD"},"shippingDestination":{"@type":"DefinedRegion","addressCountry":"US"},"deliveryTime":{"@type":"ShippingDeliveryTime","handlingTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"},"transitTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"}}}},{"@type":"Offer","name":"AI Credits — 6,000 pack","price":"49","priceCurrency":"USD","priceSpecification":{"@type":"UnitPriceSpecification","valueAddedTaxIncluded":false},"availability":"https://schema.org/InStock","url":"https://wpwebhooks.org/pricing/#credits","hasMerchantReturnPolicy":{"@type":"MerchantReturnPolicy","applicableCountry":["US","GB","CA","AU","DE","FR","NL","PL","SE","NO","DK","FI","IT","ES","PT","BE","AT","CH","IE","NZ","JP","SG","IN","BR","MX"],"returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow","merchantReturnDays":14,"returnFees":"https://schema.org/FreeReturn","returnMethod":"https://schema.org/ReturnByMail"},"shippingDetails":{"@type":"OfferShippingDetails","shippingRate":{"@type":"MonetaryAmount","value":"0","currency":"USD"},"shippingDestination":{"@type":"DefinedRegion","addressCountry":"US"},"deliveryTime":{"@type":"ShippingDeliveryTime","handlingTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"},"transitTime":{"@type":"QuantitativeValue","minValue":0,"maxValue":0,"unitCode":"DAY"}}}}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What counts as a \"site\"?","acceptedAnswer":{"@type":"Answer","text":"One domain, with its environments. A site slot is counted per domain, so example.com, www.example.com and any environment copy on a subdomain of it — staging., stage., dev., develop., test., testing., qa., uat., sandbox. or preview. — all share a single slot. Run any or all of them and it still costs one site. Separate domains, and other subdomains such as shop.example.com, demo.example.com or beta.example.com, each take a slot of their own. Local environments — localhost, *.local, *.test, and private IP addresses — are always free and never count against your site limit."}},{"@type":"Question","name":"Can I use the plugin on staging and dev sites?","acceptedAnswer":{"@type":"Answer","text":"Yes, and they do not cost you an extra slot. Local environments — localhost, *.local, *.test, and private IP addresses — are detected automatically and never consume a license slot, so you can run unlimited local installs on any plan. A public staging or dev site shares the slot of the domain it belongs to whenever it sits on one of the recognised environment subdomains — staging., stage., dev., develop., test., testing., qa., uat., sandbox. or preview. — so example.com, staging.example.com and dev.example.com together use one site. It works the other way round too: a license used only on staging.example.com, with no production site activated yet, still counts as a single site. A staging copy kept on a separate domain, such as example-staging.com, is a different domain and does take its own slot."}},{"@type":"Question","name":"What do I actually get for paying?","acceptedAnswer":{"@type":"Answer","text":"Two things that run on our infrastructure rather than yours: hosted AI credits, so Build with AI works without you managing an API key, and External Cron, which drains the delivery queue reliably without depending on WP-Cron. Plus priority support. Everything else — Code Glue, dynamic URLs, unlimited conditions and condition groups, per-webhook retry and backoff settings, the queue, the logs, the REST API and MCP — is in the free plugin, on unlimited sites. Starter, Business and Agency differ only in the number of production sites allowed (each one including its staging and dev copies) and the size of the monthly AI credit pool."}},{"@type":"Question","name":"What happens if my license expires?","acceptedAnswer":{"@type":"Answer","text":"The plugin keeps working exactly as-is — no features are locked or disabled. You lose access to updates and priority support until you renew. All your data stays intact."}},{"@type":"Question","name":"Can I upgrade my plan later?","acceptedAnswer":{"@type":"Answer","text":"Yes. Contact us and we'll upgrade your plan. We apply fair prorated pricing."}},{"@type":"Question","name":"Can I downgrade my plan?","acceptedAnswer":{"@type":"Answer","text":"Downgrades are not supported during the billing period. You can switch to a lower plan at renewal."}},{"@type":"Question","name":"What is an AI credit, and how far does it go?","acceptedAnswer":{"@type":"Answer","text":"Credits are charged by usage — how much the agent actually processes on each request — so you never overpay for a quick question or a small build, and heavier builds simply cost a bit more. A typical request runs about 11 credits, and a complete integration — the agent planning it, reading your site's hooks and REST routes, creating the webhook, mapping the fields, writing a Code Glue snippet, and test-firing it — usually lands around 25 credits total, even for an advanced multi-step build. So 5,000 credits a month is around 200 integrations, far more than most sites ever need. The plugin shows your remaining balance while you work."}},{"@type":"Question","name":"What happens when I run out of AI credits?","acceptedAnswer":{"@type":"Answer","text":"Build with AI pauses and tells you, with a link to buy a top-up pack ($19 for 2,000 credits, $49 for 6,000). Top-up credits never expire and are only spent after each month's included pool. Your included credits reset automatically every month. Nothing else in the plugin is affected — webhooks, queue, retries, Code Glue and External Cron all keep running normally."}},{"@type":"Question","name":"Do I still need my own OpenAI or Anthropic API key?","acceptedAnswer":{"@type":"Answer","text":"No. Included credits run on our servers with our provider keys, so there is nothing to sign up for and no per-token bill. If you would rather use your own key — Anthropic, OpenAI, Google, or a WordPress AI connector — that option stays available on every plan, including the free plugin. You can switch back and forth at any time in the plugin settings."}},{"@type":"Question","name":"Is there a free trial?","acceptedAnswer":{"@type":"Answer","text":"Yes. The free plugin comes with 55 AI credits, granted automatically the first time you send a prompt to Build with AI — no key, no signup, no card. That is about five requests, or two complete automations: enough to watch the agent plan an integration, map the fields, and test-fire it on your own site. When the trial credits are gone, Build with AI keeps working on your own Anthropic, OpenAI or Google key, or you can move to Pro for a monthly pool of hosted credits. Every Pro purchase is also covered by a 14-day money-back guarantee — a full refund for any reason, provided no more than 5% of your plan's monthly AI credits have been used, since credits are spent on our servers at real cost."}},{"@type":"Question","name":"What payment methods are accepted?","acceptedAnswer":{"@type":"Answer","text":"We accept all major credit and debit cards via Stripe. Payments are processed securely — we never store card details."}},{"@type":"Question","name":"Do prices include VAT or tax?","acceptedAnswer":{"@type":"Answer","text":"No — prices shown exclude VAT. Any applicable VAT or sales tax is calculated based on your billing country and shown before you confirm payment at checkout. If you are a VAT-registered business in the EU, you can enter your VAT number at checkout."}}]} ``` --- --- title: "Plugin Changelog" description: "Full release history for Webhook Actions, the free WordPress webhook plugin by Flow Systems. Every fix, feature, and improvement since v1.0.0 — updated with each release." url: "https://wpwebhooks.org/changelog/" --- [WP Webhooks](https://wpwebhooks.org/)/ Changelog # Changelog Every release of Webhook Actions — the free WordPress plugin. ## v3.2.0 2026-09-12 - → New the WP Webhooks API Docs Library. When a build targets a named service — HubSpot, Airtable, Slack, Notion, Mailchimp, SendGrid, Stripe, Telegram, Pipedrive, Trello, Discord and more — WP Webhooks AI reads that service's current API reference on our server (endpoint, auth header, body envelope, request-level flags, common errors) and builds from it instead of from memory. Available when Build with AI runs on WP Webhooks AI (Pro credits or the free trial); the reference is injected server-side and costs about one credit per turn - → New every reply built from a reference card carries a "WP Webhooks API Docs Library" pill naming the service, the operation and the date the reference was verified, linking to the vendor's documentation. A card the library researched on its own is marked auto-researched - → New a service the library has not documented yet is researched on the spot from the vendor's own docs and saved for everyone. The chat shows "Reading …'s API reference for the first time" while it waits — usually under a minute — and no credits are spent on the wait. If the reference is not ready in time the build goes ahead on the model's own knowledge and says so - → Changed: the hosted transports announce what they can do to the API (`features`), so a plugin that cannot wait for research is never held - → Changed: Build with AI now adds a credential step for any destination that needs a token — HubSpot, Airtable, Slack, an n8n header — and lets you pick or create it in the plan review, instead of asking in chat whether you have one - → Fixed a webhook URL carrying a `{{ placeholder }}` — the per-event path segment feature, e.g. `https://api.github.com/repos/{{ __repo }}/issues` — lost its braces when saved through Build with AI, so the call went to a literal path. Templates now survive the save, and payload keys that start with `__` are stripped from the body right before sending, so a value that only exists to fill the URL never reaches the vendor - → Changed: Build with AI is told how dynamic URLs work (double braces, dot path, value put into the payload first) and that static headers belong in `custom_headers` as key/value pairs, which it can now also write as a plain map - → Fixed a static header value that happens to contain a dot — `application/vnd.github+json`, a version string — was flagged in the webhook form as a payload path not found in the capture. Only something shaped like a path (`args.0.form_id`) is checked now, and the warning says the value goes out as text - → Fixed retrying or replaying deliveries from the logs looked like it did nothing on sites without External Cron. The job was re-queued but waited for the next WP-Cron visit. Every retry and replay — single or bulk — now says how many deliveries it queued and offers to run them right away; bulk replay's "Execute now" button actually had nothing to execute before; and WP-Cron is nudged after queuing so a quiet site delivers within the minute anyway ## v3.1.1 2026-09-10 - → Fixed hooks whose name a plugin builds at runtime were offered as webhook triggers even when they are filters. Advanced Custom Fields is the clearest case — `acf/update_value/type=select` and `acf/validate_field/type=text` were listed, and a webhook on a filter takes the handler's empty return as the filtered value, so choosing one silently destroyed the field being saved. Discovery now recognises a filter name assembled from fragments, and a hook nested under a confirmed filter, and refuses both. 282 such names were being offered on a site running 20 plugins; no real trigger was lost. - → Fixed the default retry backoff schedule was documented incorrectly. ## v3.1.0 2026-09-07 - → New the WP Webhooks Payload Library. When a trigger has never fired on your site, Build with AI and the mapping editor now work from a reference payload captured on our own test sites — WordPress core, WooCommerce and the major form plugins, hundreds of hooks — instead of stopping to ask you to fire the event by hand. Available when Build with AI runs on WP Webhooks AI (Pro credits or the free trial); a lookup costs no credits, and the moment the event really fires on your site, your own capture takes over. - → New a reference payload is always labelled as one. The webhook editor shows a "WP Webhooks Payload Library" badge on the trigger, with the plugin build the payload was captured on; Build with AI shows the same badge on every step built from it and on the test delivery that proves it. - → New fields inside containers only your site defines — a form's fields, order meta, ACF — are never mapped from a reference payload. Build with AI pauses on that step, names the paths, and asks you to fire the event once so the mapping is built from your real fields; the webhook editor names those containers next to the payload. - → Changed: a build made from a reference payload always ends with a test delivery, so the mapping is checked against your endpoint before the webhook can go live. - → Fixed the plugin's own filters (`fswa_payload`, `fswa_webhook_payload`, `fswa_webhook_url`, `fswa_normalize_object`) were offered as webhook triggers. Picking one silently nulled the payload of every delivery on the site; they are no longer listed. - → Fixed a trigger argument that is an object without a string form could throw inside dispatch and take the triggering request down with it. Such values are now reduced to a safe scalar and the delivery goes out. - → Fixed after a plugin update the browser could keep running the previous admin bundle from its cache; the bundle's cache-buster now changes with every build. - → Changed: reading a trigger's schema no longer rescans every active plugin to name the owning plugin's version — a first lookup that took about three seconds now takes well under one. ## v3.0.1 2026-09-04 - → Fixed the main admin navigation wrapped onto several cramped rows on phone-width screens; it now collapses into a single dropdown showing the current section. - → Fixed Build with AI's active-model bar (provider, credits, "Change model") could clip or overlap on narrow screens instead of wrapping onto its own lines. - → Fixed the Webhooks list header buttons and the retry backoff delay preview (Settings and the webhook editor) could overflow the screen on mobile. - → Fixed when a test delivery is rejected for missing authentication, Build with AI now offers the same pick-or-create credential control a failed connection check already used, instead of guessing through stored credentials on its own. - → New press Tab on Build with AI's empty prompt box to accept the example sentence currently being typed, instead of tabbing away from it. ## v3.0.0 2026-09-02 - → Changed: Code Glue, dynamic URL templates, per-webhook retry limits and backoff strategies, unlimited AND/OR conditions and publishing a build are now part of the free plugin. They used to require Webhook Actions Pro. Nothing is lost on upgrade — existing snippets, assignments and retry settings keep working exactly as they were. - → New Code Glue — PHP snippets that reshape the payload before a delivery or run side effects after the response — with a preview that runs your code against a real captured payload before you assign it. - → New dynamic `{{ field.path }}` templates in the endpoint URL, resolved against the payload at dispatch time. - → New per-webhook retry limit and backoff strategy (exponential, linear or fixed), each falling back to a site-wide default you can set in Settings. - → New conditions are no longer limited to a single rule — build as many as you need, group them, and match on ANY or ALL. - → New publish a build to wpwebhooks.org and earn AI credits, from any site, including one running only the free AI trial. Publishing is not offered from a WordPress Playground demo or from an address the internet cannot reach, and the library keeps one page per recipe — if a build like yours is already there, you are pointed at it rather than adding a near-identical second page. - → Security: writing a Code Glue snippet now requires the same capability WordPress uses for editing plugin code (`edit_plugins`), and is refused entirely on sites that set DISALLOW\_FILE\_EDIT — that is a wp-config choice and nothing in the plugin can override it. Sites that only set DISALLOW\_FILE\_MODS (common on managed hosts, where it means "do not install plugins from the dashboard") are unaffected. API tokens — including connected AI tools over MCP — cannot write snippets unless you explicitly allow it in Settings. Reading snippets is unchanged, and snippets already assigned keep running in every case. - → Note: Webhook Actions Pro 1.9.0 or later is required alongside this release. An older Pro keeps running its own copies of the moved features until you update it, so nothing breaks in the meantime. ## v2.9.0 2026-08-30 - → New connect an external AI tool to your site over MCP. Everything Build with AI can do — reading your triggers, mapping fields, creating and testing webhooks — is now reachable from Claude Code, Cursor and Claude on the web, driving the same toolset against your real configuration. See the setup guides at https://wpwebhooks.org/docs/ - → Fixed the abilities registered since 2.0.0 never actually reached the WordPress Abilities API, so nothing could discover them. Three separate causes, all silent: ability names used underscores, which core rejects; the category was registered on the wrong hook and was dropped along with everything assigned to it; and the metadata that makes an ability visible over REST and MCP was missing. All 26 are now discoverable and executable. - → Fixed abilities that take no arguments — listing your webhooks, triggers or snippets — failed over MCP with a generic error, while abilities taking a parameter worked. Their input schema declared no default, so an empty argument set never validated. - → Fixed the AI-facing webhook read now masks a manually entered authorization header, matching what the plugin's own REST endpoints have always done. This only applied to webhooks using the manual header field rather than the Credentials Vault — vault secrets are stored encrypted and have always come back as names and masked hints. Deliveries are unaffected and still send the real header. - → New destructive abilities now require explicit confirmation when called from outside the plugin. Deleting a webhook, taking one live, firing a test delivery or provisioning an application password are refused unless the call confirms the intent, so a connected AI cannot perform them on its own initiative. - → New API tokens now work against the Abilities REST route, honouring their scope exactly as the plugin's own endpoints do — a read token cannot reach a write ability, and the agent token can build without ever revealing a stored secret. - → New a setting to hold connected AI tools to read-only. Building is on by default; switching it off leaves reads working and does not affect Build with AI. - → Changed: listing webhooks now returns a short snippet of each description rather than the whole thing, so a site with long documented builds no longer sends thousands of words of context on every AI read. Fetching a single webhook still returns the full description. ## v2.8.1 2026-08-24 - → Fixed the header could show "Sent today" as a bigger number than "Total sent" — today's count exceeding the all-time count, which is impossible and reads as a broken counter. "Sent today" was counting every log row from the last 24 hours, including deliveries a condition skipped, deliveries still queued, and manual test dispatches — none of which were ever sent. It now counts the same thing "Total sent" does, just for today. ## v2.8.0 2026-08-24 - → New Build with AI now works without an API key. A fresh install can describe what it wants built straight away — the first builds run on free credits from WP Webhooks, with no account, no card and nothing to connect first. Enough to plan, build and test an automation or two. - → New the model bar shows how many free credits are left and roughly how many builds that is, counting down as the agent works. - → New when the free credits run out, Build with AI offers the ways forward — connect your own key, get a free Gemini key, or Pro credits — instead of only reporting that they are gone. Everything already built stays on the site. - → Changed: Build with AI opens on a single prompt box instead of a provider setup screen, with real example prompts typing themselves out to show what it accepts. - → Changed: with "Review plan before running" switched on, a proposed plan now says it is waiting for you to start it, rather than sitting silently while it looks stuck. - → Fixed filtered and paginated admin screens returned "not found" on sites using plain permalinks — the WordPress default on a fresh install. Delivery log filters, pagination and the AI dev trace all failed to load. - → Fixed the AI credits balance stopped updating after a request that failed because the credits had run out — the one moment it most needed to be right. - → Fixed errors coming back from the AI service were collapsed into one generic message, so a rate limit was indistinguishable from an outage. ## v2.7.4 2026-08-20 - → Changed: when you have builds but no AI provider connected, Build with AI now opens on your builds with a single "Connect" bar above them, instead of a full-height setup card that pushed everything below the fold. The same provider settings are one click away inside that bar. A site with no builds yet still sees the full setup card. ## v2.7.3 2026-08-20 - → Fixed your past builds no longer disappear when no AI provider is connected. Build with AI used to replace the whole screen with the provider setup card, hiding the build switcher and every transcript along with it — so rotating or removing an API key lost sight of everything you had built with it. The setup card now sits above your build history instead of in place of it, and past builds stay readable. - → Changed: while no provider is connected, build history is read-only — the message box, "New build" and the step actions are hidden rather than shown as buttons that cannot work. - → Tested up to WordPress 7.1. ## v2.7.2 2026-08-19 - → Improved the plugin directory listing now spells out what Webhook Actions can connect — which form plugins can act as triggers (Elementor Forms, WPForms, Forminator, Fluent Forms, Gravity Forms, plus built-in Contact Form 7 and IvyForms support) and which destinations you can send to (Slack, Discord, Airtable, Google Sheets, Mailchimp, HubSpot, Salesforce, n8n, Make, Zapier, or any other HTTP endpoint) - → Improved two new FAQ entries — using Elementor Forms, WPForms, Forminator, Fluent Forms or Gravity Forms as triggers, and sending WordPress data to Slack, Google Sheets, Airtable or a CRM - → No functional changes: this release updates the readme and directory listing only ## v2.7.1 2026-08-12 - → Fixed when Build with AI replans — after a failed test delivery, or when you ask for a change — it no longer forgets what the earlier plans already applied; a shared or published build now lists every ability the agent ran, not just the last plan's - → Fixed "Share this build" works out which webhooks a build contains from the whole run, so a build finished by a short corrective plan can no longer be shared with pieces missing - → Fixed the "Fix it" button and the automatic resume after a captured payload are now shown as messages the panel sent for you, not as something you typed — an endpoint's own error is no longer put in your mouth ## v2.7.0 2026-08-04 - → New when the trigger you picked has never fired, Build with AI now adds a "capture the payload" step to the plan and waits there instead of asking in chat — fire the event once, hit retry, and it carries on and finishes the mapping by itself - → New "Fix it" button on a failed test delivery hands the endpoint's own response back to Build with AI so it can correct the mapping or the snippet — no more copying the error into chat - → New the webhook screen shows the payload after your pre-dispatch Code Glue runs, next to the one after field mapping, so you can see the exact body that goes on the wire - → Fixed a test delivery the endpoint rejects now stops the build and shows you the response, instead of counting as a pass and letting the next step take the webhook live - → Fixed Build with AI is told what each plan step actually did, so it diagnoses a failure from the real response rather than asking you what went wrong - → Fixed Build with AI no longer guesses field paths for a trigger with no captured payload — those steps are refused until a real payload exists - → Fixed the example payload keeps refreshing until the webhook is mapped, so triggers that fire more than once (like transition\_post\_status) no longer freeze on the auto-draft WordPress writes when you open the editor - → Fixed Build with AI now knows field mapping runs before the pre-dispatch snippet, so it stops writing snippets that read $args after the mapping has removed it - → Fixed when the AI replies with a written-out plan instead of a runnable one, it is asked to resend it properly — previously "Fix it" could describe the whole repair and then do nothing at all - → New Build with AI now brings what it knows about the destination API before the first test — the envelope a record has to sit in, and request-level options like Airtable's typecast flag that no amount of reformatting a value can replace - → Fixed if the endpoint rejects a test delivery the same way twice, Build with AI is told the last change made no difference and to stop reformatting the same value — instead of looping through a third and fourth guess - → New with no AI connector and no key of your own, the AI settings now point you to a free Gemini key or to Pro's included AI credits, instead of just showing an empty key field - → Fixed the published-build address no longer picks up WordPress's own link colour when you hover it ## v2.6.0 2026-08-03 - → New after you publish a build, Build with AI waits for wpwebhooks.org to rebuild and turns the address into a link the moment your page is actually live, instead of handing you one that does not work yet (requires Pro 1.8.0) - → Fixed publishing the same build twice no longer creates a second page — publishing again links you to the page that build already has ## v2.5.0 2026-07-31 - → New Publish a build to wpwebhooks.org straight from Build with AI (Pro) — your webhooks and chains become a public page other people can import, and you earn 50 AI credits for publishing plus 20 for every like - → New Share a build from Build with AI, optionally with the AI conversation that produced it, so whoever imports it can see how it was made - → New captured example payloads are anonymized by default in every export and every published build — names, addresses, emails, phone numbers, IP addresses and user agents are replaced with placeholders, while product names, totals and structure are kept so field mapping still previews correctly - → New "Anonymize my site address" replaces your site URL with example.com everywhere it appears, including endpoint URLs and the AI conversation - → Improved long descriptions on the webhooks list now collapse behind their first line - → Fixed re-importing a build into the site it came from no longer loses the chain, and skipping duplicate webhooks no longer produces an empty one — imports also report what they had to drop instead of failing quietly - → Fixed an imported chain now appears in the list without reloading the browser ## v2.4.0 2026-07-26 - → New Import & Export — download any webhook or chain (with its triggers, field mapping, conditions, and, with Pro, Code Glue) as a portable JSON file and import it into another site, with strict validation and a per-item result summary - → New Markdown descriptions for webhooks and chains, with a Write/Preview toggle so you see the rendered result while editing - → New heads-up when Webhook Actions Pro is inactive but a webhook still relies on Pro features — a per-webhook badge and a list banner flag that Code Glue and `{{ }}` URL templates will not run (field mapping and conditions still do), with a one-click Activate Pro - → Improved the Build with AI model bar now shows the WP Webhooks AI logo and a live countdown until your monthly credits reset - → Fixed the Pre-dispatch Code Glue preview and the Test drawer now resolve the payload in the real dispatch order (field mapping first, then Code Glue), so the preview matches what is actually sent - → Fixed saving Payload Mapping or Conditions no longer blanks the live preview for webhooks that reuse a shared example payload ## v2.3.0 2026-07-22 - → New WP Webhooks AI — Pro licenses now include monthly AI credits for Build with AI, hosted by us with no API keys to configure. Pick "WP Webhooks AI (included)" under Credentials source when Webhook Actions Pro is active - → New live credits counter in Build with AI — the model bar shows how many credits are left and counts down after every agent call, with the balance, top-ups, and reset date also shown in provider settings - → New hosted status is carried on every settings response, so switching between Auto, connectors, own keys, and WP Webhooks AI never loses the option - → Improved the Pro tab now lists AI credits among Pro features - → Improved translations (Polish, Dutch, Chinese) for all new strings, plus 37 previously untranslated strings backfilled ## v2.2.2 2026-07-18 - → Fixed Build with AI can now discover and propose Gravity Forms' gform\_after\_submission trigger — some plugin hooks are fired through a wrapper function instead of a direct WordPress do\_action() call, which made them invisible to hook discovery, so the AI would fall back to a less suitable trigger - → Improved hook discovery now cross-checks WordPress's registered filters so a filter can never be mistakenly proposed as a webhook trigger, avoiding interference with the filter's normal behavior ## v2.2.1 2026-07-17 - → Fixed Build with AI no longer loses a turn when the AI provider returns a JSON reply cut off just before its final closing brace (seen in the field with Gemini's JSON mode) — a reply missing only its closing brackets is now completed and parsed, while a reply that lost real content is still rejected rather than guessed at - → Improved the AI Dev Trace now records the provider's reported finish reason for every model call, so a truncated reply is distinguishable from a token-limit stop at a glance ## v2.2.0 2026-07-14 - → New the AI Builder reads your site's own REST API contracts before building an internal automation (new get\_rest\_route\_schema read), so required fields like a user's password are handled with a Code Glue snippet — or called out honestly — instead of guessed - → New test deliveries proposed in a plan now pause for your confirmation with a clear warning when they will create or modify real data (e.g. actually create a WordPress user) - → New after a build finishes you can flip the webhook between background and synchronous delivery right in the chat — and the AI now recommends a delivery mode based on real evidence that your queue is draining - → New plan review shows the exact PHP code a Code Glue snippet step will install, so you approve the code, not just a summary - → New assistant replies stream in with a word-by-word reveal - → Improved endpoint probes only ask for confirmation on destructive methods (PUT/PATCH/DELETE); confirming a probe no longer self-rejects - → Fixed the AI prompt no longer claims Pro Code Glue is available when the Pro plugin isn't actually running — and any proposed plan steps this site can't run are surfaced as a notice instead of being silently dropped from the plan - → Dev: deliveries to your own site's REST API work on local environments with self-signed certificates (WP\_ENVIRONMENT\_TYPE=local, own host only — external endpoints always keep TLS verification) ## v2.1.3 2026-07-09 - → Fixed the AI Builder no longer creates duplicate webhooks or Application Password credentials when a model re-sends a plan it already applied — steps that were already built are recognised and reused instead of run again - → Improved when your chosen AI model can't respond and the build falls back to another model or provider, the AI Builder now shows a notice (with the reason) so you can switch models, instead of quietly answering from a different one - → Fixed a WP Application Password provisioned during a build is now automatically attached to the webhook that needs it, instead of being created but left unassigned ## v2.1.2 2026-07-08 - → Fixed the AI Builder no longer loses a turn when the AI provider returns slightly malformed JSON — a stray trailing brace, a trailing comma, or an unescaped line break is now repaired and parsed instead of failing silently - → Improved for automations that call this site's own WordPress REST API, the AI Builder proposes the full plan and lets you pick or create the auth credential right in the plan, instead of asking for it in chat - → New one-click "Create a WP Application Password for me" on the credential step (and a matching AI plan step) — it mints an Application Password for the current administrator and stores it in the vault as a ready-to-use Basic credential named "WP REST API (internal)" - → Improved the AI Builder now knows webhooks carry no code field, and points you to Code Glue (with Pro) for computed values or to the WordPress REST API for code-free internal automations ## v2.1.1 2026-07-07 - → Improved AI Builder chat shows live progress while the agent gathers site data — each read round appears as it runs instead of a silent spinner - → Fixed a failed AI provider call mid-conversation no longer loses the turn — everything gathered so far is saved, and retrying resumes from there without re-running the reads - → Fixed the agent now detects captured example payloads with no usable fields (captured by older plugin versions), shows what the capture contains, and asks for a fresh test event instead of inventing field paths - → Fixed AI test deliveries now run pre-dispatch Code Glue snippets (with Pro), matching real dispatches, and report whether a snippet ran - → Improved the trigger-catalog read supports search with capped results, and older read results are trimmed from the model's context — faster responses and fewer provider timeouts - → Improved AI provider requests allow up to 120 seconds (new `fswa_ai_http_timeout` filter) instead of timing out at 60 on large conversations - → New copy-to-clipboard buttons in the AI Dev Trace panel — full trace entry as JSON, system prompt, messages, raw request and response - → Fixed AI trace logging no longer stops silently when the day's log file was created by a CLI session ## v2.1.0 2026-07-07 - → Improved **Agentic AI Builder** — the agent can now read live site data mid-conversation (webhooks, logs, captured payloads, credential names) before proposing a plan, instead of guessing - → Improved AI responses are requested as raw JSON (Gemini, OpenAI) and conversations replay the model's exact envelopes — fixes plans failing to parse when the model wrapped them in prose or code fences - → Fixed multi-step snippet plans (with Pro) — snippet ids created in one step now resolve in the next, and stage values like `pre_dispatch` are accepted - → Fixed AI test deliveries now apply the stored field mapping, matching real dispatches - → Improved the AI ability catalog documents exact enum values and the full field-mapping format, and plans may use `{{site.url}}` / `{{site.rest_url}}` placeholders for this site's own REST API - → Improved the system prompt includes the site's home and REST URLs, so internal automations (webhooks targeting this site's own WP REST API) work without asking for the URL ## v2.0.0 2026-07-06 - → New **Build with AI** — an in-admin AI agent that builds webhook integrations and automations for you. Describe the outcome in chat; the agent inspects your site, proposes an editable plan, and on approval builds and tests the webhooks, mappings, conditions, and chains. Works plan-first with hybrid confirmation: new webhooks are created disabled, and going live / deleting / editing a live webhook requires explicit confirmation. One-click undo of the last build - → New **LLM transport auto-detection** — uses the WordPress 7.0 AI Client (`wp_ai_client_prompt()`) when a provider is configured (no API key stored), otherwise falls back to your own Anthropic, OpenAI, or Google API key stored encrypted in the Credentials Vault, with a curated model picker and automatic cross-provider fallback on rate limits - → New **WordPress Abilities API integration** — the plugin's operations (list/create/update webhooks, set mapping/conditions, test dispatch, probe endpoint, manage credentials, build chains) are registered as `flowsystems-webhook-actions/*` abilities, so external AI tools can discover and invoke the same toolset via REST and the MCP Adapter - → New **Guarded endpoint probe** — the agent can make a safe test call to a target API to validate a credential or endpoint before wiring a webhook, with an SSRF guard, rate limiting, response-size cap, and full secret redaction - → Developer: New `fswa_ability_definitions`, `fswa_ability_permitted`, and `fswa_ai_transport` filters ## v1.16.0 2026-06-22 - → New **Full internationalization** — the entire admin interface (every screen, dialog, and inline message) and all server-side strings are now translatable. The plugin is fully translatable and compatible with WPML and Polylang String Translation, and free locales can also be contributed through translate.wordpress.org - → New **Polish (pl\_PL), Simplified Chinese (zh\_CN), and Dutch (nl\_NL)** translations bundled out of the box - → Improved The admin bundle now uses a stable, hash-free filename with handle-based script-translation loading, so JavaScript translations resolve reliably across plugin updates - → Developer: Translation template (`.pot`) regenerated to cover all PHP and JavaScript strings (the Vue SPA strings are extracted from source, since `wp i18n make-pot` cannot read `.vue` files). Add a language by dropping a locale `.po`/`.mo`/`.json` set into `languages/` ## v1.15.0 2026-06-07 - → New **Credentials Vault** — store reusable authentication secrets (Bearer token, Basic auth, API key, custom header) once, encrypted at rest with AES-256-GCM, and reference them from webhooks via a saved credential instead of pasting a raw Authorization header. Secrets are **write-only** over the REST API — never returned to any caller, only a masked hint (e.g. `Bearer ****1234`) — and are decrypted only at dispatch time to build the outgoing header - → New **`agent` API token scope** — grants full write access (create/update/delete webhooks and credentials) for AI assistants, while never being able to reveal a webhook's `auth_header` or any vault secret. Groundwork for safe AI-driven webhook management - → New **`FSWA_SECRET_KEY` wp-config constant** (optional hardening) — by default the encryption key is auto-generated and stored in the database (zero-config, survives WordPress salt rotation); define `FSWA_SECRET_KEY` to move it out of the database. Adding it never breaks existing credentials (decryption falls back to the old key), and an in-app one-click migration re-encrypts everything with the new key and then deletes the database key - → New **"Save to vault"** action on the webhook form — migrate an existing manual Authorization header into the encrypted vault in one step (`Bearer …` is detected automatically; anything else is stored as a custom Authorization value) - → Improved Resolved Authorization and custom auth headers are now redacted (`[redacted]`) when persisted to delivery logs, so a decrypted secret never lands in the log store - → Developer: New REST endpoints under `/fswa/v1/credentials` — list/create/update/delete (all `full`/`agent` scope), plus `GET /credentials/key-status` and `POST /credentials/reencrypt`. Webhooks accept an `auth_credential_id` reference (takes precedence over the legacy `auth_header`, which still works) - → DB adds the `fswa_credentials` table and an `auth_credential_id` column on `fswa_webhooks`; idempotent, safe to run on existing installs ## v1.14.1 2026-06-05 - → Fixed "Get Pro" links updated to `/pricing/` page - → Improved Admin Menu moved to its own settings card - → Improved External Cron description expanded in README ## v1.14.0 2026-06-03 - → New **Activity History** — persistent audit log of every admin and API-token action across webhooks, tokens, settings, logs, queue, schemas, chains, and cron. Each entry records the actor (session user or API token with a name hint), the action type, a structured context diff (old → new values for updates), and a timestamp. Designed to trace automated and AI-assisted changes end-to-end - → New **AI prompt and reasoning capture** — REST requests carrying `X-FSWA-Prompt` or `X-FSWA-Reason` headers have those values stored in the activity log context and surfaced in the Activity view as a highlighted card above the change diff; intended for AI agents that perform admin actions via API tokens - → New **External Cron page** \*(Pro)\* — dedicated admin page for managing the external cron trigger: enable/disable toggle, mode selector, configurable interval and batch-size sliders, a live heartbeat chart (line + bar combo), monitor status bar, and an alert banner when the last ping failed or is stale - → New `fswa_cron_token_regenerated` action hook — fires after the cron secret is regenerated, allowing integrations (e.g. Pro Uptime Kuma sync) to update their stored monitor URL - → Fixed created and deleted activity log entries for webhooks and tokens now include the full entity data (name, URL, scope, etc.) in context instead of an empty object - → Fixed webhook toggle events now correctly capture old and new `is_enabled` state; webhook, settings, and schema update events capture a full old/new diff - → Fixed chain and chain-link CRUD actions (create, update, delete, link add/remove) are now covered by Activity History - → Improved Snippets (Pro) added to the Activity History filter list; Activity tab repositioned after External Cron in the navigation - → DB adds `fswa_activity_logs` table; idempotent, safe to run on existing installs ## v1.13.1 2026-05-21 - → Fixed Replay and Retry buttons on the single-webhook logs page (`#/webhooks/:id/logs`) did nothing — the events emitted by the table were never handled. Both the row button and the Log Details panel button now work correctly, including the "Execute Now" flow and the replay success dialog - → Fixed bumped tested-up-to WordPress version to 7.0 ## v1.13.0 2026-05-18 - → New **Webhook Chains** — a webhook completing successfully (2xx) can now trigger one or more downstream webhooks. Chains are first-class entities with names and links; each chained webhook receives the upstream response body, sent payload, and pre-mapping original payload as its starting `args`, so the next step can map fields off the previous step's response (e.g. capture a HubSpot deal ID and pass it to a Create Line Items webhook). Replaces ad-hoc `wp_remote_post` calls in post-dispatch snippets with proper webhooks that get logs, retries, conditions, headers, and URL templates - → New Triggers section on Edit Webhook now offers a "Use other Webhooks as triggers" toggle. When enabled the webhook is wired as a chain target — pick an existing chain or create a new one, then select which upstream webhooks should fire it (with a search box for filtering long lists). Defaults to synchronous execution so the response is available immediately - → New Webhooks list view groups webhooks by chain with a left accent border and chain icon; a webhook involved in multiple chains is rendered in each group. An "Unchained" section holds webhooks with no chain involvement - → New Chain group headers now have inline **Rename** and **Delete** buttons. Deleting a chain shows an impact callout listing which downstream webhooks would become orphans, and a chain is automatically removed when its last link is deleted - → New Orphan badge — any webhook left with no triggers (after a chain or link deletion, for instance) shows a destructive-styled broken-link icon and "No trigger assigned" label so it stays visible and editable in the list - → New Deleting a webhook involved in any chain shows an enhanced confirmation modal listing the affected chains and downstream impact, then cascade-removes the chain links and their synthetic trigger rows - → New Logs and Queue views render chain-link triggers as human-readable pills (`Chain name ← Source webhook`) instead of the raw `fswa_chain_link:N` synthetic name, and gain a "Filter by chain" dropdown that narrows the list to deliveries triggered by a specific chain - → New REST endpoints `/fswa/v1/chains` (CRUD) and `/fswa/v1/chains/{id}/links` (manage source→target edges). Cycles are rejected at save time across all chains. Both `/logs` and `/queue` list endpoints now accept a `chain_id` query parameter - → Fixed `X-Event-Id` and `X-Event-Timestamp` request headers were emitted empty when Payload Mapping or Code Glue pre-dispatch reshaped the payload and dropped the `event` block. Headers now resolve from the log row first (single source of truth) and only fall back to the payload if needed - → Fixed payload preview in the Payload Mapping editor could break the page layout when a captured payload string contained HTML (e.g. a chain-link trigger carrying an upstream HTML response body in `args.0.response.raw_body`). String values are now properly HTML-escaped before being injected into the highlighted JSON preview — also closes a stored-XSS surface in the admin UI - → DB new `fswa_chains` and `fswa_chain_links` tables; idempotent, safe to run on existing installs ## v1.12.2 2026-05-14 - → Fixed "Queue appears stuck" health banner staying visible when delivery logs were left in `pending` state with no live queue row (e.g. legacy rows from older queue-status semantics, or worker crashes between updating queue and log state); the queue processor now reconciles such orphaned pending logs on every run and marks them `permanently_failed` - → Improved trigger conditions UI — renamed the "Evaluate against" toggle to "Evaluate conditions against" and added an inline info tooltip explaining the Original (pre-mapping) vs Transformed (post-mapping) choice - → Removed misleading Pro upgrade badge from the conditions evaluate-against toggle — choosing between original and transformed payload for conditions has always been a free-plan feature ## v1.12.1 2026-05-12 - → Docs corrected README to reflect that type casting (in both payload mapping and conditions) is a free-plan feature; removed misleading Pro markers - → Docs added WooCommerce → n8n step-by-step example showing conditional dispatch wired up via a Claude Code agent - → Docs reformatted `fswa_webhook_url` PHP code sample so it renders as a single code block on the wordpress.org plugin page - → No code changes ## v1.12.0 2026-05-12 - → New `fswa_webhook_payload` filter — Pro-extensible enrichment of the outgoing payload before dispatch; receives the mapped payload, webhook id, trigger name, and pre-mapping original payload - → New `fswa_glue_post_dispatch` action — fires after every delivery with response code/body, mapped payload, webhook, and original pre-mapping payload; intended for Pro post-dispatch snippets - → New `fswa_webhook_url` filter — Pro-extensible URL template expansion; passes the URL, post-glue payload, webhook, trigger, and original pre-mapping payload as fallback for dot-notation token resolution - → New per-trigger `conditions_evaluate_on` setting (original / transformed) — choose whether conditions evaluate against the pre-mapping payload or the post-mapping/post-glue payload; segmented toggle in the trigger schema panel - → New dual-resolution for custom headers and URL parameters — when a dot-notation path resolves to null in the post-glue payload, falls back to the pre-mapping original payload; matches existing condition resolution semantics - → New collapsible Original Payload section in the Mapping editor — inspect the pre-glue/pre-mapping payload while authoring field mappings - → Improved `object_contains` operator — also matches when the value is present at the current array level, not only nested; works for array-typed WooCommerce fields like `meta_data` and `line_items` - → Improved delivery log writes — `request_payload`, `original_payload`, and `mapping_applied` are now refreshed after the `fswa_webhook_payload` filter mutates the payload - → Fixed condition evaluation order — re-evaluates conditions after Code Glue pre-dispatch when `conditions_evaluate_on` is `transformed` - → Fixed pre-glue filter application in synchronous mode — applied exactly once during the inline attempt instead of once before enqueue and again at send ## v1.11.0 2026-05-06 - → New `array_contains` condition operator — checks whether an array field contains a specified value; works with flat arrays and arrays of objects - → New `object_contains` condition operator — checks whether an object field contains a specified key (optionally filtered to a specific property within nested objects using a `key=` parameter) - → New `stringify` type cast — JSON-encodes array and object field values into a string before comparison, enabling string-based operators on complex nested structures - → Improved FieldSelector — split navigate and select actions; added a dedicated "+" button to select non-leaf fields (arrays and objects) directly without drilling further into children - → Improved ConditionsEditor layout — responsive three-row design on small screens (field + delete on top row, cast + operator on second row, value on third); `object_contains` exposes an inline property name input when a key filter is needed - → Delivery log detail messages now include the property key when `object_contains` is matched against a specific property ## v1.10.0 2026-05-03 - → New per-webhook synchronous execution mode — when enabled, the webhook fires inline during the WordPress request that triggers it, bypassing the queue; a warning dialog explains the performance impact before enabling; dismissal can be stored permanently per-browser - → First synchronous attempt runs blocking in the current request; retryable failures (5xx, transport errors) automatically fall back to the async queue starting at attempt 2 with standard exponential backoff; non-retryable failures (4xx) are marked permanently failed immediately - → New sync execution toggle to the Webhooks list view — enable or disable per webhook without opening the edit screen - → New Request Headers and Query Parameters sections to delivery log details — inspect the exact headers and URL parameters sent with each delivery - → New collapsible Request Payload and Original Payload sections in delivery log details — collapse state is persisted in browser storage so the panel opens in the same state on next visit - → Fixed GET and DELETE webhooks not including custom headers or URL parameters in deliveries - → New replay support for skipped (condition-failed) log entries — re-evaluate a previously skipped event after changing the webhook's conditions ## v1.9.0 2026-05-03 - → New configurable HTTP method per webhook — choose GET, POST, PUT, PATCH, or DELETE (default: POST) - → New custom request headers per webhook — define key/value pairs sent with every delivery; values support dot-notation paths resolved against the outgoing payload - → New URL query parameters per webhook — appended to the endpoint URL; for GET and DELETE requests, query params are the primary payload transport (no body); a full `?payload=` fallback is used when no params are configured - → New `fswa_capture_payload` filter — modify or enrich the payload stored as the captured example without affecting what is dispatched; designed for Pro extensions and custom PHP snippets - → New `fswa_webhook_response` action — fires after every HTTP response is received per webhook; intended for Pro extensions to run custom logic against the response (parse body, trigger follow-up actions, store data) - → New `request_headers` and `request_url` columns to delivery logs — the exact headers sent and the fully resolved URL (with query params applied) are now stored and visible in the delivery log - → Improved test webhook drawer — defaults to "Captured + Mapping" payload source; result panel now shows HTTP method, fully resolved endpoint URL, sent headers, and request body ## v1.8.0 2026-04-28 - → New type casting in Conditions — cast field values to number, string, or boolean before comparison; enables greater than / less than on numeric strings (e.g. WooCommerce price "100.50") - → New type casting in Payload Mapping — cast field values before sending to external APIs - → New `X-Webhook-Id` request header — sent with every delivery; carries the webhook's stable UUID so downstream systems can identify which webhook configuration triggered the request when multiple webhooks share the same endpoint - → Fixed test webhook result label — now reflects actual HTTP status: 2xx = Success, 3xx = Redirect, 4xx = Client Error, 5xx = Server Error (previously all completed deliveries showed green "Success") ## v1.7.0 2026-04-27 - → New "Test Webhook" delivery with run-now and queue modes — test webhook delivery without triggering real WordPress events - → New conditional webhook dispatch — filter events by payload field values before dispatch; free plan includes one condition with AND match - → New field selector with live preview in the Conditions editor to build conditions from real example payloads - → Fixed attempt history timestamps displayed in browser local time - → Renamed plugin to "Webhook Actions by Flow Systems" - → New Webhook Actions Pro integration tab for license management ## v1.6.2 2026-04-05 - → Fixed graceful handling of 409 responses when a queue job was already completed in a background process - → Fixed mapping editor not supporting dot-containing keys (e.g. Gravity Forms sub-field IDs like `6.1`) ## v1.6.1 2026-03-28 - → Fixed schema API endpoints for triggers containing forward slashes (e.g. `ivyforms/form/before_submission`) returning 404 on Apache — admin now uses double-encoding to pass through Apache's encoded-slash restriction ## v1.6.0 2026-03-28 - → New built-in IvyForms integration — automatically normalizes IvyForms field objects and enriches submission payloads for `ivyforms/form/before_submission` and `ivyforms/form/after_submission` hooks - → New IntegrationLoader to centralize third-party integration registration - → Fixed forward slashes not being recognized in hook names during dynamic trigger discovery - → Fixed percent-encoded slashes in schemas REST route trigger param not being decoded correctly - → Fixed trigger name not being URL-encoded when building schemas API requests from the admin UI ## v1.5.0 2026-03-23 - → New built-in CF7 to webhook integration — automatically sends CF7 submissions as structured webhook payloads (form id, title, fields, meta, uploaded files) - → New `fswa_normalize_object` filter for custom third-party object normalization - → New `get_properties()` fallback in payload normalization to handle objects with private or protected properties - → Improved hook registration to capture all hook arguments by default (PHP\_INT\_MAX accepted\_args) ## v1.4.0 2026-03-22 - → New Action Scheduler support for queue processing (auto-detected, no configuration required) - → Automatic migration from WP-Cron to Action Scheduler when available - → New option to move admin menu under Tools for cleaner dashboard navigation - → New dynamic trigger discovery via static PHP source scan - → Reduced triggers API responses size - → Fixed input focus styles in admin forms ## v1.3.2 2026-03-15 - → Fixed `auth_header` field being exposed to API tokens without `full` scope — read and operational tokens now receive a permission notice instead ## v1.3.1 2026-03-15 - → Fixed log details dialog showing error message from the first attempt instead of the most recent one - → New \[REST API Reference\](https://wpwebhooks.org/webhook-wordpress-plugin-api/) link to the plugin description ## v1.3.0 2026-03-15 - → New API token authentication for the REST API — create tokens with `read`, `operational`, or `full` scope; tokens are SHA-256 hashed at rest and accepted via `X-FSWA-Token` header, `Authorization: Bearer`, or `?api_token=` query param - → New token expiry support with optional `expires_at`; expired tokens are rejected at auth time and visually flagged in the admin panel - → New token rotation — issues a new secret while preserving all other token fields; optionally updates expiry in the same request; revived expired tokens auto-extend to +30 days by default - → New `PATCH /tokens/{id}` endpoint for updating `expires_at` independently of rotation - → New `fswa_api_tokens` database table (migration 1.3.0) - → Applied scope-based dual auth (`manage_options` session OR valid token) to all existing REST controllers: `read` for GET endpoints, `operational` for toggle/retry/replay, `full` for create/update/delete - → Fixed all admin UI date displays (logs, queue, schema panel) to show times in the user's local timezone instead of raw UTC - → Fixed date range filters (logs, queue) to correctly convert local picker values to UTC before querying - → Improved log details panel — error message, response body, HTTP code, and duration now reflect the most recent attempt history entry rather than the top-level log fields ## v1.2.1 2026-03-07 - → Fixed retry returning 500 when a log has multiple queue jobs (replay + original) — `findByLogId` now returns the most recent job via `ORDER BY id DESC` - → Fixed `forceRetry` rejecting jobs with status `failed` — restored `failed` to the allowed status list alongside `pending` and `permanently_failed` ## v1.2.0 2026-03-07 - → New persistent delivery stats table (`fswa_stats`) for long-term aggregation - → New replay button for successful log entries - → New "Execute Now" button in replay dialog with auto-open log details - → New full attempt history with response body, accordion UI, and next attempt countdown - → Replaced browser `confirm()` dialogs with modal confirmations - → Fixed queue stats — removed stale `failed` status, added `permanently_failed` - → Fixed retry eligibility check to use log status instead of queue job status - → Fixed "Execute Now" button visibility to only show for pending jobs ## v1.1.1 2026-03-01 - → Fixed `permanently_failed` entries being excluded from total and error delivery statistics in `getStats()`, `getAllTimeStats()`, and `LogArchiver::aggregateStatsBeforeDeletion()` ## v1.1.0 2026-02-28 - → New event identity: each trigger dispatch generates a shared UUID and timestamp sent as `X-Event-Id` / `X-Event-Timestamp` headers and embedded in the payload under `event.{id,timestamp,version}` - → New smart retry routing: 5xx and 429 responses trigger an automatic retry with exponential backoff; 4xx and 3xx responses are immediately marked as permanently failed - → New `permanently_failed` status for non-retryable delivery failures - → New attempt history: each delivery attempt is recorded as a JSON array on the log entry, visible in the admin timeline view - → New per-log retry and bulk retry REST endpoints (`POST /logs/{id}/retry`, `POST /logs/bulk-retry`) - → New `event_uuid` and `target_url` filter parameters to logs and queue REST endpoints - → New date range filtering (`date_from`, `date_to`) to logs and queue list views with a shadcn-style calendar date/time picker - → New health observability metrics: average attempts per event, oldest pending age, queue stuck detection, WP-Cron-only warning - → New `queue.log_id` column linking queue jobs to their log entries - → Updated admin UI: permanently failed badge, attempt timeline, per-row retry button, bulk retry, observability warning banners, new filter inputs - → Updated footer with a review prompt linking to WordPress.org ## v1.0.1 2026-02-18 - → Fixed preview freezing when mapping fields from objects with numeric string keys (e.g. WooCommerce line\_items) - → Fixed orphaned pending log entries caused by logPending() silently failing — queue jobs now carry mapping metadata and recover a proper log entry if the original ID was lost - → Enhanced normalizeValue to handle Closure, DateTimeInterface, and Traversable types - → Removed unnecessary WooCommerce hook patterns from trigger exclusions - → Improved log details display with word break for long trigger names and dates ## v1.0.0 2026-02-16 - → Initial release - → Webhook dispatching from WordPress actions - → Background processing with retry mechanism - → Configurable webhook payloads - → Logging of webhook deliveries /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"WebPage","name":"Plugin Changelog — Webhook Actions","description":"Full release history for Webhook Actions, the free WordPress webhook plugin by Flow Systems.","url":"https://wpwebhooks.org/changelog/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","url":"https://wpwebhooks.org/wordpress-webhook-plugin/"}} ``` --- --- title: "Pro Changelog — Webhook Actions Pro" description: "Full release history for Webhook Actions Pro — the premium WordPress webhook plugin by Flow Systems. Every fix, feature, and improvement since v1.0.0." url: "https://wpwebhooks.org/pro-changelog/" --- [WP Webhooks](https://wpwebhooks.org/)/ Pro Changelog # Pro Changelog Every release of Webhook Actions Pro — the premium add-on. ## v1.10.0 2026-09-09 - → New the WP Webhooks API Docs Library. When a build targets a named service — HubSpot, Airtable, Slack, Notion, Mailchimp, Stripe and more — WP Webhooks AI now reads that service's current API reference on our server (endpoint, auth header, body envelope, request-level flags, common errors) and builds from it instead of from memory. Build with AI shows a "WP Webhooks API Docs Library" pill under every reply built this way, with the service, the operation and the date the reference was verified - → New a service we have not documented yet is researched on the spot from the vendor's own docs and saved for everyone; the chat says "Reading …'s API reference for the first time" while it waits, usually under a minute, and no credits are spent on the wait - → Requires Webhook Actions 3.2.0 or newer for the pill and the wait notice; on 3.1.x the reference is still used, silently ## v1.9.0 2026-09-02 - → Changed: Code Glue, dynamic URL templates, per-webhook retry limits and backoff strategies, unlimited AND/OR conditions and publishing a build are no longer Pro features — they now ship in the free Webhook Actions plugin, and this release hands them over. Your snippets, assignments and retry settings are untouched and keep working exactly as they were - → Changed: Pro is now hosted AI credits and External Cron — the two things that run on our infrastructure rather than yours - → New a site that published builds on the free AI trial and then bought a licence keeps them. The pages, the reward ledger and the credits publishing earned move onto the licence when it is activated on that site - → **Requires Webhook Actions 3.0.0 or newer.** Update the free plugin first; until you do, this release will not load, and the version you have now keeps running the moved features so nothing breaks in between ## v1.8.4 2026-08-30 - → Fixed the plugin package no longer carries our own deployment scripts. They were inert — nothing loaded or ran them — but they had no business being inside your site, and they are now excluded from the download - → Still requires Webhook Actions 2.7.1 or newer ## v1.8.3 2026-08-17 - → Fixed copying a site to a new address — a staging clone, or a real domain migration — went completely unnoticed. The licence kept reporting the seat count from the moment of the copy instead of registering the new address, and the copy went on reading the External Cron monitor belonging to the site it came from, right down to being able to pause it - → Pro now records which address its cached state belongs to and reacts when that changes: it stops using the other site's monitor, gives this site its own cron token, and re-validates the licence against the address it is actually running on - → The old site's monitor is deliberately left running rather than paused — a copy has no business stopping the original's queue — and an admin notice explains what changed and what still needs a person - → Fixed an http-to-https or www change is no longer mistaken for a move to a different site, which would have stranded the monitor and created a duplicate beside it - → Fixed no External Cron monitor is created for a local development address, which nothing on the internet can reach ## v1.8.2 2026-08-12 - → Fixed a build you share or publish now lists every ability Build with AI ran, across every plan it went through — previously a build that failed a test delivery and was fixed by a short corrective plan credited the agent with only that last plan's steps - → Fixed the "Fix it" button and the automatic resume after a captured payload travel as messages the panel sent for you, so a shared conversation no longer attributes an endpoint's own error to you - → Requires Webhook Actions (free) 2.7.1 or newer, which adds the applied-step history this reads ## v1.8.1 2026-08-04 - → Fixed previewing a Code Glue snippet now runs it against the payload the snippet actually gets at dispatch — after your field mapping — so a snippet that reads $args no longer previews green and then quietly does nothing in production - → New the preview shows you the exact array the snippet received, so you can check the keys your code reads are really there before assigning it - → New a snippet that reads $args when the mapping has removed it is reported instead of silently doing nothing — the notice lists the keys that ARE available - → Still requires Webhook Actions (free) 2.5.0 or newer ## v1.8.0 2026-08-03 - → Fixed publishing the same build twice no longer creates a second page — publishing again points you at the page that build already has, and the duplicate cannot be created in the first place - → New after publishing, the plugin can tell you when your page is actually live — wpwebhooks.org rebuilds after every publish, so the address it hands back takes about a minute and a half to start working (shown in Build with AI from free 2.6.0 onward) - → Still requires Webhook Actions (free) 2.5.0 or newer ## v1.7.0 2026-07-31 - → New Publish a build to wpwebhooks.org straight from Build with AI — your webhooks and chains become a public page anyone can import, and you earn 50 AI credits for publishing plus 20 every time someone likes it - → New the Build-with-AI conversation can travel with a shared or published build, opt-in and off by default, so whoever imports it can see how it was made - → Improved personal data in a shared conversation is anonymized the same way the free plugin anonymizes captured example payloads — emails, IP addresses and phone numbers quoted back by the agent are replaced with placeholders - → Requires Webhook Actions (free) 2.5.0 or newer, which adds the publish/share document and its export hooks ## v1.6.0 2026-07-26 - → **New: Code Glue travels with Import & Export** — exporting a webhook now includes its pre and post Code Glue snippets, and importing recreates them on the target site - → Requires **Webhook Actions (free) 2.4.0** or newer, which adds the import/export engine and its per-trigger hooks ## v1.5.0 2026-07-22 - → New WP Webhooks AI — every Pro license now includes monthly AI credits for Build with AI, hosted by us with no API keys to configure. Activating a license switches the builder to the hosted service automatically (an explicit own-keys or WordPress-connectors choice is never overridden), and the balance updates live after every agent call - → New out-of-credits, rate-limit, and provider errors surface directly in the builder chat, with a buy-more-credits link and the option to switch back to your own keys at any time - → Improved Polish, Dutch, and Chinese translations for all new strings, plus three Code Glue ability descriptions that had shipped untranslated - → Requires free plugin >= 2.3.0 ## v1.4.0 2026-07-15 - → New **`fswa_glue_error` action** — a Code Glue snippet that errors during a real dispatch now fires `do_action( 'fswa_glue_error', $webhook_id, $trigger, $stage, $snippet_id, $error )` and writes to the PHP error log. The delivery itself is never broken: the payload is sent unmodified, exactly as before, but the failure is no longer silent - → Improved assigning a snippet to a webhook that is still **disabled** (the build under review) no longer asks the AI agent for confirmation. Attaching to a **LIVE** webhook still requires it, with a notice explaining that the snippet's PHP starts running on real traffic immediately - → Requires free plugin >= 2.2.0 ## v1.3.1 2026-07-07 - → Fixed `assign_snippet` AI ability now accepts the stage variants models actually emit (`pre_dispatch`, `post-dispatch`) and normalizes them to `pre`/`post` - → Fixed AI-written snippet code wrapped in `` tags is normalized to plain PHP instead of failing at run time (create, update, and preview) - → Improved refreshed bundled banners and animated update icon for the AI-first 2.0 narration ## v1.3.0 2026-07-06 - → New **AI writes Code Glue** — seven Code Glue abilities for the free plugin's Build with AI agent (and external MCP agents): list, get, create, update, preview, assign, and delete snippets. The agent drafts PHP, test-runs it against your real captured payloads via preview, and assigning or deleting a snippet always requires your confirmation - → New With Pro active, the AI agent can propose **multi-rule AND/OR condition groups** instead of a single rule - → New Polish (pl\_PL), Simplified Chinese (zh\_CN), and Dutch (nl\_NL) translations for the AI snippet abilities and External Cron strings - → Docs REST API conditions examples, including cast types and the schemas endpoint reference ## v1.2.1 2026-06-22 - → New **Translations** — the Pro plugin is now fully translatable (text domain `flowsystems-webhook-actions-pro`) and ships Polish (pl\_PL), Simplified Chinese (zh\_CN), and Dutch (nl\_NL). Compatible with WPML and Polylang String Translation - → Improved Animated plugin icon on the update screen (refreshed artwork with more padding) - → Internal: Translation template (`.pot`) added; the license-notice, base-version-mismatch notice, and REST validation strings are wrapped for localization ## v1.2.0 2026-06-03 - → New **External Cron** — replaces unreliable WP-Cron with an Uptime Kuma-based external cron monitor. Two modes: `plugin_endpoint` pings the FSWA queue processor on a configurable interval; `wp_cron` pings wp-cron.php directly and optionally adds `DISABLE_WP_CRON` to wp-config.php. Monitor lifecycle (create, pause, resume) is managed automatically on license activate/deactivate. REST: `GET /pro/external-cron/settings`, `PUT /pro/external-cron/settings`, `GET /pro/external-cron/stats`, `POST /pro/external-cron/pause`, `POST /pro/external-cron/resume` - → New **Uptime Kuma monitor URL sync** — when the cron secret is regenerated from the free plugin, the Pro plugin automatically rebuilds the monitor URL via the `fswa_cron_token_regenerated` action so the next Kuma ping uses the updated token - → New **Activity log coverage for Code Glue** — snippet CRUD (create, update, delete) and trigger assignments (pre/post snippet enable/disable) are now recorded in Activity History with structured old/new context diffs. Requires free plugin 1.14.0 - → New **Automatic plugin updates** — the Pro plugin now self-updates via the WordPress plugin update mechanism; update checks poll the Flow Systems license server and surface available Pro releases in the standard WordPress Updates screen - → New **License notice** — an admin notice is shown when the Pro license is invalid, expired, or not yet activated, linking directly to the license settings screen - → Improved Plugin updater reads and sets the version dynamically during release so the in-plugin version and update payload stay in sync - → Requires free plugin >= 1.14.0 ## v1.1.0 2026-05-12 - → New **Code Glue** — attach PHP snippets to any webhook+trigger pair. Pre-dispatch snippets run before the payload is sent and can modify or enrich it (must `return $array`). Post-dispatch snippets run after a successful delivery for side-effects (return value is ignored). Uses `{{ $var.path }}` shorthand for PHP array access (e.g. `{{ $payload.site.url }}` → `$payload['site']['url']`) - → New Global snippet library — create, tag, search, and reuse snippets across all webhooks. REST: `/fswa/v1/pro/snippets` (CRUD), `/fswa/v1/pro/snippets/{id}/preview` - → New Per-webhook trigger snippet assignments — assign a pre and/or post snippet per webhook+trigger pair, each with an independent enable toggle. REST: `/fswa/v1/pro/trigger-snippets/{webhook_id}/trigger/{trigger}` - → New `{{ $var.path }}` URL template syntax in endpoint URLs, resolved against the live outgoing payload via `fswa_webhook_url` filter — enables dynamic URLs like `https://api.example.com/deals/{{ _hs_deal_id }}` without custom PHP - → New DB tables `fswa_pro_snippets` and `fswa_pro_trigger_snippets` — idempotent, safe on existing installs - → Requires free plugin >= 1.12.0 (1.14.0 required for External Cron feature) ## v1.0.0 2026-05-07 - → Initial release - → Pro license gate scaffolding — license key activation, validation, and site-limit enforcement - → Integration tab in the free plugin admin panel for license management /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"WebPage","name":"Pro Changelog — Webhook Actions Pro","description":"Full release history for Webhook Actions Pro, the premium WordPress webhook plugin by Flow Systems.","url":"https://wpwebhooks.org/pro-changelog/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions Pro","url":"https://wpwebhooks.org/pricing/"}} ``` --- --- title: "About" description: "Webhook Actions is a WordPress plugin for reliable webhook delivery — async queue, retry, replay, and a full REST API. Built by Mateusz Skorupa." url: "https://wpwebhooks.org/about/" --- [WP Webhooks](https://wpwebhooks.org/)/ About # Webhook Actions is a WordPress plugin for reliable webhook delivery. An async delivery queue, configurable retry with exponential backoff, full delivery log, one-click replay, and a REST API — all inside WordPress. ## What Webhook Actions does. Webhooks from WordPress fail silently. A slow endpoint, a momentary outage, or a payload that doesn't match what a downstream API expects — and the event is gone. No retry. No log. No way to know it happened. Webhook Actions replaces the fire-and-forget delivery model with a proper async queue. Every outgoing request is logged, retried on failure, and replayable from the admin UI or via REST API. Code Glue — PHP snippets that run pre- and post-dispatch — and dynamic URL templates that inject payload values straight into endpoint paths ship in the free plugin too. Pro adds only hosted AI credits and External Cron. Free GPL Core Open source, no lock-in Pro AI credits + Cron Hosted AI credits, External Cron REST API Full CRUD Manage webhooks via REST Retry + Replay Built in Configurable backoff per webhook [WordPress.org](https://wordpress.org/plugins/flowsystems-webhook-actions/) [GitHub](https://github.com/flowsystems-pl/wordpress-webhook-actions) [Plugin details](https://wpwebhooks.org/wordpress-webhook-plugin/) ## Technical content on WordPress webhook architecture. This site publishes in-depth examples and guides covering webhook delivery patterns, WordPress hook architecture, WooCommerce integrations, and CRM automation. All content is written for engineers building real production systems. [Blog](https://wpwebhooks.org/blog/) [Examples](https://wpwebhooks.org/examples/) [API docs](https://wpwebhooks.org/docs/) ## Created by Mateusz Skorupa. Mateusz is an automation engineer and WordPress developer based in Poland. He started Webhook Actions after building the same retry-and-log scaffolding on multiple client projects and decided to extract it into a proper plugin. He runs Flow Systems, a consulting practice that designs and builds webhook-driven integrations for WooCommerce stores and SaaS platforms — primarily connecting WordPress to CRMs, ERPs, and custom internal APIs. [flowsystems.pl](https://flowsystems.pl) [LinkedIn](https://linkedin.com/in/mateuszskorupa) [GitHub](https://github.com/MateuszSkorupa1) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"WebPage","name":"About WP Webhooks","url":"https://wpwebhooks.org/about/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://flowsystems.pl"}} ``` --- --- title: "Action Scheduler Support" description: "Automatically uses Action Scheduler (when available) instead of WP-Cron for queue processing." url: "https://wpwebhooks.org/docs/action-scheduler/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Action Scheduler Support Feature since v1.4.0 # Action Scheduler Support Automatically uses Action Scheduler (when available) instead of WP-Cron for queue processing. When Action Scheduler is detected (e.g. via WooCommerce), the plugin migrates queue processing from WP-Cron to Action Scheduler automatically — no configuration required. Action Scheduler provides more reliable background execution, persistent job tracking, and better performance under high-volume traffic. / Related [Async Delivery Queue](https://wpwebhooks.org/docs/async-queue/)[fswa\_queue\_batch\_size](https://wpwebhooks.org/docs/fswa-queue-batch-size/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Action Scheduler Support — Feature — Webhook Actions Docs","description":"Automatically uses Action Scheduler (when available) instead of WP-Cron for queue processing.","url":"https://wpwebhooks.org/docs/action-scheduler/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Activity History" description: "Persistent audit log of every admin and API-token action with actor identity, structured diffs, and AI prompt capture." url: "https://wpwebhooks.org/docs/activity-history/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Activity History Feature since v1.14.0 # Activity History Persistent audit log of every admin and API-token action with actor identity, structured diffs, and AI prompt capture. Every create, update, delete, and toggle performed through the admin UI or via an API token is recorded in the Activity History log. Each entry captures who did it (session user or API token name), what changed (structured old → new diff), the affected object, IP address, and a timestamp. The log is browsable and filterable by action type, object type, object ID, actor, and date range. ## Covered actions Webhooks (create, update, delete, toggle), API tokens (create, delete, rotate), settings, delivery logs (bulk delete), queue jobs (execute, retry), trigger schemas (save), chains and chain links (create, update, delete), cron token regeneration, snippet CRUD and per-trigger snippet assignments. ## AI prompt and reasoning capture When an AI agent (Claude Code, Cursor, or any programmatic client) performs an action via an API token, it can annotate the request with `X-FSWA-Prompt` and `X-FSWA-Reason` headers. These are stored in the activity log context as `_prompt` and `_reason` and surfaced in the Activity view as a highlighted card above the change diff — making it easy to trace exactly which instruction caused a change. ## REST access Activity entries are accessible via `GET /wp-json/fswa/v1/activity` (requires `read` scope) with filters for `action`, `action_prefix`, `user_id`, `object_type`, `object_id`, `date_from`, and `date_to`. Bulk delete via `DELETE /wp-json/fswa/v1/activity?older_than_days=N` requires `full` scope. / Related [API Tokens](https://wpwebhooks.org/docs/api-tokens/)[REST API](https://wpwebhooks.org/docs/rest-api/)[Developer Extensibility](https://wpwebhooks.org/docs/developer-extensibility/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Activity History — Feature — Webhook Actions Docs","description":"Persistent audit log of every admin and API-token action with actor identity, structured diffs, and AI prompt capture.","url":"https://wpwebhooks.org/docs/activity-history/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "AI Dev Trace" description: "A diagnostic panel for Build with AI that records the exact prompts, raw requests, and raw responses exchanged with the AI model — API keys always redacted." url: "https://wpwebhooks.org/docs/ai-dev-trace/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / AI Dev Trace Feature since v2.0.0 # AI Dev Trace A diagnostic panel for Build with AI that records the exact prompts, raw requests, and raw responses exchanged with the AI model — API keys always redacted. AI Dev Trace gives you full visibility into what the Build with AI agent sends and receives. Each model call is recorded as one entry — system prompt, the message transcript sent, the raw HTTP request (endpoint, headers, body, with API keys replaced by `[redacted]`), the raw response, latency, and whether the reply parsed into a valid plan. Entries are grouped into per-day accordions in the panel, newest first. ## Two switches, off by default Settings → AI Builder → "Enable AI Dev Trace" makes the panel visible on the Build with AI screen; the panel's own Logging switch controls whether calls are recorded (`fswa_ai_debug`). Both are off by default — leave them off unless you are troubleshooting a build. ## Where traces live Traces are JSONL files, one per day, in `wp-content/uploads/fswa-ai-logs/` — the directory is web-guarded with an index file and deny rules. Secrets never hit disk: provider API keys are redacted before writing, and the payload redaction that protects prompts applies here too. The panel offers one-click clear. / Related [Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[Activity History](https://wpwebhooks.org/docs/activity-history/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"AI Dev Trace — Feature — Webhook Actions Docs","description":"A diagnostic panel for Build with AI that records the exact prompts, raw requests, and raw responses exchanged with the AI model — API keys always redacted.","url":"https://wpwebhooks.org/docs/ai-dev-trace/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Any Hook as a Trigger" description: "Use any WordPress do_action hook as a webhook trigger — no built-in integration needed." url: "https://wpwebhooks.org/docs/any-hook-integration/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Any Hook as a Trigger Feature since v1.0.0 # Any Hook as a Trigger Use any WordPress do\_action hook as a webhook trigger — no built-in integration needed. Any WordPress action hook can be used as a webhook trigger. Paste the hook name into the trigger field and the plugin will listen for it, capture all hook arguments as the payload, and dispatch to your endpoint. This means any form plugin — Gravity Forms, Ninja Forms, WPForms, or a fully custom plugin — works without requiring a dedicated integration. / Setup 1. 1 **Create a new webhook** Go to Webhook Actions in the WordPress admin and add a new webhook. Give it a name that matches your use case, e.g. "Gravity Forms → n8n". 2. 2 **Paste the hook name as the trigger** Set the trigger to any WordPress action hook — e.g. gform\_after\_submission for Gravity Forms. The plugin hooks into it automatically, no code needed. 3. 3 **Set the webhook URL** Paste your endpoint URL (e.g. your n8n webhook URL). The plugin will POST all hook arguments here on every firing. 4. 4 **Save and test** Trigger the action (e.g. submit the form). Check the Event Log in the plugin admin to see delivery status and the captured payload. / Demo / Related [Contact Form 7 Integration](https://wpwebhooks.org/docs/cf7-integration/)[IvyForms Integration](https://wpwebhooks.org/docs/ivyforms-integration/)[fswa\_payload](https://wpwebhooks.org/docs/fswa-payload/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Any Hook as a Trigger — Feature — Webhook Actions Docs","description":"Use any WordPress do_action hook as a webhook trigger — no built-in integration needed.","url":"https://wpwebhooks.org/docs/any-hook-integration/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "API Docs Library" description: "A hosted reference for third-party APIs — HubSpot, Airtable, Slack, Notion, Stripe and dozens more — read into every Build with AI turn that targets one, so the request is built from the vendor's current docs instead of the model's memory." url: "https://wpwebhooks.org/docs/api-docs-library/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / API Docs Library Feature since v3.2.0 # API Docs Library A hosted reference for third-party APIs — HubSpot, Airtable, Slack, Notion, Stripe and dozens more — read into every Build with AI turn that targets one, so the request is built from the vendor's current docs instead of the model's memory. A language model remembers an API the way it was documented when the model was trained: an old version path, a body envelope that has since changed, a required field it never saw. Build with AI used to build HubSpot, Airtable or Notion calls from that memory, and a wrong envelope cost a whole turn to find out. The API Docs Library closes the gap. When a build names a service the library knows — HubSpot, Airtable, Slack, Notion, Mailchimp, SendGrid, Stripe, Telegram, Pipedrive, Trello, Discord, Google Sheets, Brevo, ActiveCampaign, Klaviyo, Twilio, Zendesk, Salesforce, Jira, GitHub, Resend, Postmark, n8n, Make, Zapier and more — WP Webhooks AI reads that service's current reference card on our server and puts it in front of the model before it plans: endpoint and method, the auth header and how it enters the plan, the body envelope, request-level flags, and the errors people actually hit. A service nobody has a card for yet is researched on the spot from the vendor's own documentation and saved for everyone. ## What a card holds One card covers one operation of one service — HubSpot "Create contact", GitHub "Create an issue". It gives the exact URL and method (HubSpot's date-versioned `/crm/objects/2026-09/contacts`, not the v3 path the model remembers), the auth header and the fact that it should arrive as a credential step rather than a question in chat, the body envelope with a worked example, request-level options such as an upsert flag or an idempotency key, and the 4xx responses that come up in practice with what causes each — a HubSpot portal with GDPR enabled rejecting a contact that lacks `hs_legal_basis`, for instance. Every card names the vendor page it was verified against and the date, and every URL on it has to sit on that vendor's own documented hosts, so a card can never point the agent at an address the docs do not. ## How a build is matched to a card The service comes from your own words — the name or a known alias, so "Sendinblue" still finds Brevo — and the operation from the nouns and verbs around it: "create a contact" and "update the contact" land on different HubSpot cards. Only what you typed counts. Site data the agent reads back — a credential called "HubSpot PAT", a webhook named after HubSpot — is never mistaken for intent, so a card does not tag along into an unrelated turn. When nothing in the registry matches, one short extraction pass on the first turn asks the model which service and operation you meant, and the miss is recorded either way. ## The pill under the reply Every reply built from a card carries a "WP Webhooks API Docs Library" pill: the service, the operation, the date the reference was verified, and a link to the vendor's page. A card the library wrote for itself is marked auto-researched, so you can see when the request shape came from a curated card and when it came from the same research you could have done. The pill needs the free plugin at 3.2.0 or newer; an older plugin still gets the card, silently. ## Researched on the spot The first time anyone asks for a service and operation the library has no card for, the server goes and reads the vendor's docs — a search, the pages it finds, fetched with a real browser where the vendor's site refuses plain requests — and writes the card from what it read, with a rule that every claim on it must trace back to a page it retrieved. The chat shows "Reading …'s API reference for the first time…" while it waits, usually under a minute, and the wait spends no credits. If the card is not ready in time the build goes ahead on the model's own knowledge and says so; the card is finished in the background and serves the next turn. A curated card always beats a researched one, researched cards are re-read on a schedule, and there is a daily ceiling on research so a run of unusual requests cannot turn into an unbounded bill. ## Dynamic URLs it now writes correctly A GitHub issue lands in `/repos/{owner}/{repo}/issues`, and the repository often lives in post meta. Build with AI knows the plugin's `{{ field.path }}` URL templates and writes such a call as `https://api.github.com/repos/{{ __repo }}/issues`, with a Code Glue snippet that puts `__repo` into the payload first. A key that starts with `__` exists only to fill the URL or a header: it is stripped from the body right before the request goes out, so a value that was never meant for the vendor never reaches it. Static headers such as GitHub's `Accept` and API-version headers go into the webhook's custom headers. ## What it costs and when it is consulted The lookup runs on the hosted service, so it is available while Build with AI runs on WP Webhooks AI — the free trial or a Pro credit pool. A site on its own provider key keeps building from the model's memory. A card is a few hundred tokens of extra context on the turn that uses it and is billed like the rest of the turn, which works out to about one credit. It fails open: an unreachable library, a card that will not fit, a research run that times out, all land the build back on exactly what it did before, and the reply says when that happened. / Related [Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[WP Webhooks AI](https://wpwebhooks.org/docs/wp-webhooks-ai/)[Payload Library](https://wpwebhooks.org/docs/payload-library/)[Credentials Vault](https://wpwebhooks.org/docs/credentials-vault/)[assign\_credential](https://wpwebhooks.org/docs/ability-assign-credential/)[Dynamic URL Templates](https://wpwebhooks.org/docs/dynamic-url-templates/)[Code Glue](https://wpwebhooks.org/docs/code-glue/)[AI Dev Trace](https://wpwebhooks.org/docs/ai-dev-trace/)[Browse every service in the library](https://wpwebhooks.org/api-library/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"API Docs Library — Feature — Webhook Actions Docs","description":"A hosted reference for third-party APIs — HubSpot, Airtable, Slack, Notion, Stripe and dozens more — read into every Build with AI turn that targets one, so the request is built from the vendor's current docs instead of the model's memory.","url":"https://wpwebhooks.org/docs/api-docs-library/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "API Tokens" description: "Scoped API tokens for REST API access — create tokens with read, operational, full, or agent scope." url: "https://wpwebhooks.org/docs/api-tokens/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / API Tokens Feature since v1.3.0 # API Tokens Scoped API tokens for REST API access — create tokens with read, operational, full, or agent scope. Create tokens from the API Tokens screen in the admin panel. Each token has a name, scope, optional expiry, and is SHA-256 hashed at rest. Tokens are accepted via `X-FSWA-Token` header, `Authorization: Bearer`, or `?api_token=` query parameter. Token management itself always requires a WordPress admin session. ## Scopes `read` — GET endpoints only. `operational` — read + toggle webhooks, retry/replay logs, execute queue jobs. `full` — operational + create, update, delete webhooks, schemas, logs; reveals stored auth secrets. `agent` — same write access as `full`, but can never reveal a webhook's `auth_header` or any Credentials Vault secret. Intended for AI assistants. / Related [REST API](https://wpwebhooks.org/docs/rest-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"API Tokens — Feature — Webhook Actions Docs","description":"Scoped API tokens for REST API access — create tokens with read, operational, full, or agent scope.","url":"https://wpwebhooks.org/docs/api-tokens/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "assign_credential — AI Ability" description: "Assign a vault credential to a webhook — Reference a stored vault credential from a webhook by id (the secret is injected at dispatch time and never exposed)." url: "https://wpwebhooks.org/docs/ability-assign-credential/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / assign\_credential AI Ability since v2.0.0 # assign\_credential Assign a vault credential to a webhook — Reference a stored vault credential from a webhook by id (the secret is injected at dispatch time and never exposed). Reference a stored vault credential from a webhook by id (the secret is injected at dispatch time and never exposed). Registered as the WordPress Ability `flowsystems-webhook-actions/assign-credential`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "assign_credential", "input": { "webhook_id": …, "credential_id": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. Runs without a confirmation pause (the overall plan is still user-approved before execution starts). / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | webhook\_id | param | integer | yes | | | credential\_id | param | integer,null | — | | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "assign_credential", "summary": "…", "input": { "webhook_id": 12, "credential_id": 3 } } ``` / Related [list\_credentials](https://wpwebhooks.org/docs/ability-list-credentials/)[Credentials Vault](https://wpwebhooks.org/docs/credentials-vault/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"assign_credential — AI Ability — Webhook Actions Docs","description":"Assign a vault credential to a webhook — Reference a stored vault credential from a webhook by id (the secret is injected at dispatch time and never exposed).","url":"https://wpwebhooks.org/docs/ability-assign-credential/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "assign_snippet — AI Ability" description: "Assign a Code Glue snippet to a webhook — Attach a snippet to a webhook+trigger as its pre-dispatch (payload transform) or post-dispatch (after-response side effects) Code Glue and enable it." url: "https://wpwebhooks.org/docs/ability-assign-snippet/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / assign\_snippet AI Ability since v1.3.0 # assign\_snippet Assign a Code Glue snippet to a webhook — Attach a snippet to a webhook+trigger as its pre-dispatch (payload transform) or post-dispatch (after-response side effects) Code Glue and enable it. Attach a snippet to a webhook+trigger as its pre-dispatch (payload transform) or post-dispatch (after-response side effects) Code Glue and enable it. Requires confirmation — the snippet's PHP runs on every real dispatch from then on. Pass snippet\_id 0 to unassign, or enabled false to keep it assigned but paused. Registered as the WordPress Ability `flowsystems-webhook-actions/assign-snippet`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "assign_snippet", "input": { "webhook_id": …, "trigger": …, "stage": …, "snippet_id": …, "enabled": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. This step always pauses for explicit user confirmation before it runs. External MCP/REST callers get a 428 and must re-send the same call with `"confirmed": true`. / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | webhook\_id | param | integer | yes | | | trigger | param | string | yes | | | stage | param | string | yes | pre = transform payload before dispatch; post = run after the response. pre | post | | snippet\_id | param | integer | yes | 0 to unassign. | | enabled | param | boolean | — | | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "assign_snippet", "summary": "…", "input": { "webhook_id": 12, "trigger": "woocommerce_order_status_completed", "stage": "pre", "snippet_id": 4, "enabled": true } } ``` / Related [preview\_snippet](https://wpwebhooks.org/docs/ability-preview-snippet/)[Code Glue](https://wpwebhooks.org/docs/code-glue/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"assign_snippet — AI Ability — Webhook Actions Docs","description":"Assign a Code Glue snippet to a webhook — Attach a snippet to a webhook+trigger as its pre-dispatch (payload transform) or post-dispatch (after-response side effects) Code Glue and enable it.","url":"https://wpwebhooks.org/docs/ability-assign-snippet/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Async Delivery Queue" description: "Webhook events are written to a persistent queue and delivered outside the request cycle." url: "https://wpwebhooks.org/docs/async-queue/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Async Delivery Queue Feature since v1.0.0 # Async Delivery Queue Webhook events are written to a persistent queue and delivered outside the request cycle. When a watched WordPress action fires, the plugin inserts a delivery job into its own database table and schedules it for background processing. The original PHP request returns immediately — no blocking HTTP calls. Jobs survive server restarts and are processed in configurable batches. ## Queue backends Action Scheduler is auto-detected (used by WooCommerce) and preferred when available. The plugin falls back to WP-Cron when Action Scheduler is not installed. System cron can also process the queue via the token-authenticated REST endpoint. ## Job lifecycle A job starts as `pending`, moves to `processing` during delivery, then resolves to `success`, `failed` (retryable), or `permanently_failed`. Failed jobs re-enter the queue with exponential backoff. Each attempt is recorded with HTTP status, response body, and duration. / Related [Smart Retry with Exponential Backoff](https://wpwebhooks.org/docs/smart-retry/)[Synchronous Execution](https://wpwebhooks.org/docs/synchronous-execution/)[Action Scheduler Support](https://wpwebhooks.org/docs/action-scheduler/)[Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Async Delivery Queue — Feature — Webhook Actions Docs","description":"Webhook events are written to a persistent queue and delivered outside the request cycle.","url":"https://wpwebhooks.org/docs/async-queue/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Build with AI" description: "An in-admin AI agent that turns plain-language requests into working, tested webhook integrations — plan-first, with confirmation before anything goes live." url: "https://wpwebhooks.org/docs/build-with-ai/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Build with AI Feature since v2.0.0 # Build with AI An in-admin AI agent that turns plain-language requests into working, tested webhook integrations — plan-first, with confirmation before anything goes live. Build with AI is the plugin's in-admin agent. Describe the outcome — "When a Contact Form 7 form is submitted, send it as JSON to my n8n webhook" — and the agent proposes a step-by-step plan, then executes it: creates the webhook (always disabled at first), works from a real example payload — your site's own capture where one exists, otherwise a reference payload from the Payload Library — and, when the destination is a named third-party service, from that service's current API reference in the API Docs Library, maps fields against the actual payload shape, sets dispatch conditions, probes the endpoint with an SSRF-guarded test call, and sends a real test delivery. Steps that go live or change data pause for your confirmation, and the last change can be undone with one click. On a fresh install there is nothing to connect first — a free trial covers your first builds the moment you send a prompt. ## Free credits, nothing to connect A brand-new install can build straight away. Sending your first prompt claims a free trial — 55 credits, issued to your site by our API with no key, no account and no card. That is roughly five agent turns, or two complete automations. Before you spend anything the model bar reads "Free trial — starts with your first prompt" alongside the credit count; once claimed it switches to "N credits left" and counts down as you build, and the same figures appear on the Free trial card in Settings → AI Builder. One trial per site, and it is never refilled. It is also the last source Auto reaches for, so a site that has connected WordPress connectors or its own API key keeps using those and never spends trial credits. When the trial is used up, Build with AI carries on as soon as you connect your own provider key — a free Google AI Studio key is enough — or activate Pro for a monthly pool of hosted credits. ## Model sources Four ways to connect a model, none of them required to get started. With a Pro license the simplest is WP Webhooks AI (included) — hosted credits that run on our servers with no keys to configure (see the WP Webhooks AI doc). The others are WordPress connectors (the WordPress 7.0 AI Client, using your site's configured providers, with no keys stored by the plugin), My own keys (Anthropic, OpenAI, or Google — encrypted in the Credentials Vault), or Auto, which prefers WordPress connectors, falls back to your own keys, and only then to the free trial. Activating a Pro license switches to the hosted service without overriding an explicit own-key or connector choice, and if a provider is rate-limited mid-build the agent automatically retries on another connected provider. ## Grounded in real site data The agent works from real payloads, not guesses: field paths in mappings and conditions come from example payloads (`args.0.form_id`, never invented names) — this site's own capture first, and for a trigger that has never fired here, a reference payload from the Payload Library, always labelled as one. The request going out is grounded the same way: when the destination is HubSpot, Airtable, Notion or another service in the API Docs Library, the agent works from that service's current reference card — endpoint, auth, envelope, known errors — instead of what the model remembers, and the reply says so with a pill. Existing webhooks are edited by id instead of duplicated, and test deliveries verify the integration end to end. Before building an internal automation that calls your own site, it reads the target REST route's real argument contract (via `get_rest_route_schema`), so required fields like a user's password are handled with a Code Glue snippet — or called out honestly — instead of guessed. Payload field values with sensitive-looking names (passwords, tokens, keys) are redacted before any prompt is built, and stored credentials are never sent to the model. ## You approve what goes live Plans run in order and pause for confirmation before anything real happens: a test delivery that would create or modify actual data (e.g. really create a WordPress user) shows a clear warning first, and a plan review shows the exact PHP a Code Glue snippet step will install — you approve the code, not just a summary. After a build finishes you can flip the webhook between background and synchronous delivery right in the chat, with a recommended mode based on real evidence that your queue is draining. ## The same tools, everywhere Every operation the agent uses is registered through the WordPress Abilities API as `flowsystems-webhook-actions/*`, so external AI tools like Claude Code and Cursor can drive the identical toolset over MCP or REST with a scoped API token. See the AI Abilities reference in these docs for every tool. ## Nothing the agent builds is gated Every ability the agent has works on the free plugin — including agent-driven Code Glue, where it writes a PHP snippet, test-runs it against your payload with `preview_snippet`, and assigns it after your confirmation, and multi-rule AND/OR condition groups. Since 3.0.0 there is no licence check anywhere in the abilities layer. What Pro adds is the fuel and the timing: a renewing pool of hosted AI credits so you are not bringing your own key, and External Cron so the queue drains without WP-Cron. / Related [WP Webhooks AI](https://wpwebhooks.org/docs/wp-webhooks-ai/)[Payload Library](https://wpwebhooks.org/docs/payload-library/)[API Docs Library](https://wpwebhooks.org/docs/api-docs-library/)[Share a build](https://wpwebhooks.org/docs/share-a-build/)[Publish a build](https://wpwebhooks.org/docs/publish-a-build/)[create\_webhook](https://wpwebhooks.org/docs/ability-create-webhook/)[set\_conditions](https://wpwebhooks.org/docs/ability-set-conditions/)[get\_rest\_route\_schema](https://wpwebhooks.org/docs/ability-get-rest-route-schema/)[Get a free Google AI Studio API key](https://wpwebhooks.org/docs/get-google-ai-studio-api-key/)[AI Dev Trace](https://wpwebhooks.org/docs/ai-dev-trace/)[Credentials Vault](https://wpwebhooks.org/docs/credentials-vault/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Build with AI — Feature — Webhook Actions Docs","description":"An in-admin AI agent that turns plain-language requests into working, tested webhook integrations — plan-first, with confirmation before anything goes live.","url":"https://wpwebhooks.org/docs/build-with-ai/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Captured payload anonymization" description: "Personal data in captured example payloads is replaced with placeholders before a build leaves your site." url: "https://wpwebhooks.org/docs/payload-anonymization/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Captured payload anonymization Feature since v2.5.0 # Captured payload anonymization Personal data in captured example payloads is replaced with placeholders before a build leaves your site. A captured example payload is the one part of a build nobody wrote — the plugin recorded whatever a real visitor actually submitted. A WooCommerce order carries the billing and shipping address, the customer's email, phone number, IP address and user agent. Every export and every published build now replaces those with placeholders by default. ## Shape is preserved on purpose The example payload exists so an importer can preview field mappings, and a preview needs the same keys, the same nesting and the same types. So every replacement is a plausible value of the same kind — `jane.doe@example.com`, `1 Example Street`, `203.0.113.10` — never a deleted key. Product names, totals, statuses, SKUs and IDs are left exactly as captured. ## What it looks for Names, emails, phone numbers, street addresses, cities, postcodes, companies, IP addresses, user agents, dates of birth, and identifiers that unlock something (order keys, session ids, tokens). Field-name matching handles prefixes, so `_billing_email` and `customer_email` are treated the same, and `{key, value}` pairs — WooCommerce `meta_data`, ACF, most form plugins — are judged by the field name they declare rather than by the literal key `value`. Free text is scanned for emails, IP addresses and international phone numbers, which is what catches values the AI conversation quotes back at you. ## What is deliberately untouched Field mappings, conditions, headers and URL params are configuration you wrote and reviewed, so they are left intact — blanking them would destroy what the build means. ## The one opt-out A download offers "Keep the real captured values" for a private backup of your own site. Publishing ignores it entirely. / Related [Import & Export](https://wpwebhooks.org/docs/import-export/)[Share a build](https://wpwebhooks.org/docs/share-a-build/)[Publish a build](https://wpwebhooks.org/docs/publish-a-build/)[Payload Mapping](https://wpwebhooks.org/docs/payload-mapping/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Captured payload anonymization — Feature — Webhook Actions Docs","description":"Personal data in captured example payloads is replaced with placeholders before a build leaves your site.","url":"https://wpwebhooks.org/docs/payload-anonymization/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Code Glue" description: "Attach short PHP snippets per webhook+trigger to enrich payloads before dispatch." url: "https://wpwebhooks.org/docs/code-glue/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Code Glue Feature since v1.7.0 # Code Glue Attach short PHP snippets per webhook+trigger to enrich payloads before dispatch. Free from 3.0.0 — Code Glue shipped as a Pro feature in 1.7.0 and is now part of the free plugin, with no licence check on it or on the abilities that drive it. Write a pre-dispatch PHP snippet in the admin panel and attach it to a webhook+trigger combination. The snippet runs after payload mapping and can read WordPress data, look up post meta, call internal functions, or reshape fields without touching theme or plugin files. ## Available variables `$payload` — the mapped payload array. `$args` — shortcut to `$payload['args']`, the original hook arguments. ## Shorthand syntax Curly-brace placeholders resolve to array access for any variable: `{{ $args.0.field }}` → `$args[0]['field']`, `{{ $payload.key }}` → `$payload['key']`. ## Return value Must `return $payload;` — the returned array replaces the payload before dispatch. / Examples Look up and inject a HubSpot deal ID from post meta ``` // Available: $payload (mapped array), $args ($payload['args']) $order_id = $payload['order_id'] ?? 0; $hs_deal_id = get_post_meta( $order_id, '_hs_deal_id', true ); $payload['hs_deal_id'] = $hs_deal_id; return $payload; ``` / Related [Post-Dispatch Scripting](https://wpwebhooks.org/docs/post-dispatch-scripting/)[Payload Mapping](https://wpwebhooks.org/docs/payload-mapping/)[fswa\_webhook\_payload](https://wpwebhooks.org/docs/fswa-webhook-payload/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Code Glue — Feature — Webhook Actions Docs","description":"Attach short PHP snippets per webhook+trigger to enrich payloads before dispatch.","url":"https://wpwebhooks.org/docs/code-glue/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Conditional Dispatch" description: "Events are only delivered when payload field values match configured rules; non-matching events are logged as skipped." url: "https://wpwebhooks.org/docs/conditional-dispatch/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Conditional Dispatch Feature since v1.7.0 # Conditional Dispatch Events are only delivered when payload field values match configured rules; non-matching events are logged as skipped. Define conditions per webhook+trigger. Each condition specifies a dot-notation field path, an optional type cast, an operator, and a comparison value. There is no limit on how many conditions a webhook may carry, and rules can be nested into groups with AND/OR logic between them. ## Operators `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `greater_than`, `less_than`, `is_empty`, `has_value`, `array_contains`, `object_contains`. ## Evaluate against Conditions can be evaluated against the original pre-mapping payload or the transformed post-mapping payload. Choose per webhook in the conditions settings panel. / Related [Unlimited Conditions](https://wpwebhooks.org/docs/unlimited-conditions/)[Payload Mapping](https://wpwebhooks.org/docs/payload-mapping/)[fswa\_should\_dispatch](https://wpwebhooks.org/docs/fswa-should-dispatch/)[Webhook Replay](https://wpwebhooks.org/docs/webhook-replay/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Conditional Dispatch — Feature — Webhook Actions Docs","description":"Events are only delivered when payload field values match configured rules; non-matching events are logged as skipped.","url":"https://wpwebhooks.org/docs/conditional-dispatch/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Configurable HTTP Methods" description: "Each webhook can send requests as GET, POST, PUT, PATCH, or DELETE." url: "https://wpwebhooks.org/docs/http-methods/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Configurable HTTP Methods Feature since v1.9.0 # Configurable HTTP Methods Each webhook can send requests as GET, POST, PUT, PATCH, or DELETE. The HTTP method is configurable per webhook. POST is the default. GET and DELETE requests have no body — the payload is sent as query parameters instead (or as `?payload=` JSON-encoded fallback if no params are configured). POST, PUT, and PATCH send a JSON body with `Content-Type: application/json`. / Related [Custom Request Headers](https://wpwebhooks.org/docs/custom-headers/)[URL Query Parameters](https://wpwebhooks.org/docs/url-query-params/)[fswa\_http\_args](https://wpwebhooks.org/docs/fswa-http-args/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Configurable HTTP Methods — Feature — Webhook Actions Docs","description":"Each webhook can send requests as GET, POST, PUT, PATCH, or DELETE.","url":"https://wpwebhooks.org/docs/http-methods/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Connect Claude Code or Cursor to your webhooks over MCP — How-to Guide" description: "Let an AI tool on your machine read your triggers and delivery logs, and build webhooks for you, by connecting it to your site over MCP." url: "https://wpwebhooks.org/docs/connect-claude-code-and-cursor/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Connect Claude Code or Cursor to your webhooks over MCP How to since v2.9.0 # Connect Claude Code or Cursor to your webhooks over MCP Let an AI tool on your machine read your triggers and delivery logs, and build webhooks for you, by connecting it to your site over MCP. Everything Build with AI can do inside your dashboard is also published as WordPress Abilities. Any tool that speaks the Model Context Protocol — Claude Code, Cursor and others — can therefore discover and drive the same toolset from outside WordPress, once something is installed to serve them over MCP — either the [WordPress MCP Adapter](https://github.com/WordPress/mcp-adapter) itself, or [Enable Abilities for MCP](https://wordpress.org/plugins/enable-abilities-for-mcp/), which bundles it: list the `do_action` hooks your site actually fires, read why a delivery failed, create a webhook and fire a test at it. Nothing is proxied through us; the AI talks to your site directly, and your site decides what it is allowed to do. ## If the endpoint will not respond Check `https://your-site.com/wp-json/` in a browser first. If it redirects or 404s, your permalinks are set to Plain — change that under Settings → Permalinks and save, or path-based REST URLs never resolve and no MCP client can reach the server. This is the most common cause of a connection that looks broken for no reason. ## What a connected tool is allowed to do Reads are always available: your webhooks, triggers, trigger schemas, delivery logs and snippet list. Writing is on by default and can be switched off in Settings, which holds connected tools to read-only without affecting Build with AI. Deleting a webhook, taking one live, firing a test delivery and provisioning an application password are refused unless the call carries `"confirmed": true`, so no agent performs them on its own initiative. Secrets are never returned: stored credentials come back as names and masked hints only. ## Application Passwords or API tokens? The MCP endpoint authenticates as a WordPress user, so it needs an Application Password. The plugin’s own scoped API tokens work on the Abilities REST route (`/wp-json/wp-abilities/v1/abilities//run`) instead, which is the better fit for scripts: a `read` token can call the read abilities and nothing else, and an `agent` token can build but can never reveal a stored secret. ## Why you only see three tools The MCP Adapter does not publish each ability as its own MCP tool. Its server exposes a fixed trio — `discover-abilities`, `get-ability-info` and `execute-ability` — and an AI reaches any registered ability through `execute-ability`. The practical consequence is that your AI tool cannot offer separate per-tool approval for, say, reading a log versus deleting a webhook: to it they are one tool called with different arguments. That is why Webhook Actions enforces its own confirmation on destructive abilities rather than relying on the client to do it. ## Ability names use dashes Inside Build with AI an ability is `create_webhook`. On the Abilities and MCP surface the same ability is `flowsystems-webhook-actions/create-webhook` — WordPress core rejects underscores in ability names, so they are converted at the boundary. Use the dashed form in anything you call from outside WordPress. ## What about Claude on the web? The setup above is for tools that can send a static header. Claude.ai custom connectors instead need a full OAuth 2.1 sign-in, which the MCP Adapter does not provide — that route needs a second plugin for the OAuth layer, and a host that lets WordPress answer `/.well-known/` paths. It does work: see \[Connect Claude on the web\](/docs/connect-claude-ai/). / Setup 1. 1 **Get an MCP server running** WordPress 6.9 and newer ship the Abilities API, which is where Webhook Actions publishes its toolset. Something still has to serve those abilities over MCP, and there are two ways to get that. The official WordPress MCP Adapter is the direct route, but it is not in the plugin directory yet, so it has to be built from source with Composer. The easier route is to install Enable Abilities for MCP from Plugins → Add Plugin and switch on "claude.ai OAuth Custom Connector" under Settings → WP Abilities → Connection: it carries its own copy of the adapter, and that switch starts both servers — including the one this guide uses. Either way you end up with the same endpoint. 2. 2 **Create an Application Password** In WordPress go to Users → Profile, scroll to Application Passwords, name one after the tool you are connecting, and click Add Application Password. Copy the password WordPress shows you — it is displayed once, and the spaces in it are fine to keep. Create it on an administrator account: the abilities check the same capability the plugin’s own screens do. Each one can be revoked individually later without disturbing the others. 3. 3 **Add the server to your AI tool** Point the tool at your site’s MCP endpoint and give it the Application Password as HTTP Basic credentials. In Claude Code that is a single claude mcp add command; in Cursor it is an entry in mcp.json. Both are shown below. If you took the Enable Abilities route, its Connection tab prints the exact URL and will assemble the credentials for you from your username and Application Password. 4. 4 **Check the connection** Run \`/mcp\` in Claude Code, or ask the tool what it can do. You will see three tools rather than a long list — discover-abilities, get-ability-info and execute-ability. That is how the MCP Adapter works: it exposes those three, and everything Webhook Actions offers is reached through them. Ask it to discover the available abilities and you should get all of ours back by name. 5. 5 **Put it to work** Try "show me which hooks this site fires when an order completes, then wire that to my n8n endpoint and test it". The AI reads your real trigger list rather than guessing, and a test delivery tells it whether the endpoint actually accepted the payload. / Examples Claude Code — add the server ``` # username:application-password, base64-encoded CREDS=$(printf '%s' "admin:abcd EFGH ijkl MNOP qrst UVWX" | base64 -w0) claude mcp add --transport http webhook-actions \ https://example.com/wp-json/mcp/mcp-adapter-default-server \ --header "Authorization: Basic $CREDS" ``` Cursor — mcp.json ``` { "mcpServers": { "webhook-actions": { "url": "https://example.com/wp-json/mcp/mcp-adapter-default-server", "headers": { "Authorization": "Basic " } } } } ``` Confirming a destructive call ``` // Deleting, enabling or test-firing a webhook is refused until it is // confirmed, so an agent cannot do any of them by accident: { "code": "fswa_confirmation_required", "data": { "status": 428 } } // The same call, once a human has approved it: { "input": { "id": 12, "confirmed": true } } ``` / Related [Connect Claude on the web to your webhooks](https://wpwebhooks.org/docs/connect-claude-ai/)[Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[API Tokens](https://wpwebhooks.org/docs/api-tokens/)[REST API](https://wpwebhooks.org/docs/rest-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Connect Claude Code or Cursor to your webhooks over MCP — How-to Guide — Webhook Actions Docs","description":"Let an AI tool on your machine read your triggers and delivery logs, and build webhooks for you, by connecting it to your site over MCP.","url":"https://wpwebhooks.org/docs/connect-claude-code-and-cursor/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Connect Claude on the web to your webhooks — How-to Guide" description: "Add your site to claude.ai as a custom connector, so you can ask Claude about your webhooks and have it build them — from the browser, with no terminal." url: "https://wpwebhooks.org/docs/connect-claude-ai/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Connect Claude on the web to your webhooks How to since v2.9.0 # Connect Claude on the web to your webhooks Add your site to claude.ai as a custom connector, so you can ask Claude about your webhooks and have it build them — from the browser, with no terminal. Claude Code and Cursor connect with a single header, but [claude.ai](https://claude.ai) needs a full OAuth 2.1 sign-in before it will talk to a server. The [WordPress MCP Adapter](https://github.com/WordPress/mcp-adapter) does not provide one, so this route uses [Enable Abilities for MCP](https://wordpress.org/plugins/enable-abilities-for-mcp/) instead — a plugin directory install that ships both an embedded OAuth 2.1 server and its own copy of the adapter, so it is the only thing you need to add. Once connected, Claude signs in as a WordPress user and gets that user’s permissions: it can read your triggers, webhooks and delivery logs, and build automations for you, in an ordinary browser conversation. This is a more involved setup than the terminal route, and one step of it depends on your host — read the last section before you start. ## What you need before you start A publicly reachable HTTPS site — this is a browser-to-server OAuth flow, so `localhost` and sites behind a VPN or HTTP basic-auth wall cannot complete it. Permalinks must be set to something other than Plain, or `/wp-json/` URLs do not resolve and no MCP client can reach the server. You also need a paid Claude plan, because custom connectors are not available on the free tier. If either is a problem, \[Claude Code or Cursor\](/docs/connect-claude-code-and-cursor/) will connect to a local site with nothing more than an application password. ## Why Claude only sees three tools The MCP Adapter does not publish each ability as its own tool. It exposes a fixed trio — `discover-abilities`, `get-ability-info` and `execute-ability` — and everything runs through the last one. So Claude cannot offer separate approval for reading a log versus deleting a webhook: to it they are one tool with different arguments. That is why Webhook Actions enforces its own confirmation on destructive actions instead of relying on the client, and why setting `execute-ability` to "ask each time" is worth the extra clicks. ## What a connected Claude is allowed to do Reads are always available: webhooks, triggers, trigger schemas, delivery logs and your snippet list. Building is on by default and can be switched off in Settings, which holds connected tools to read-only without affecting Build with AI. Deleting a webhook, taking one live, firing a test delivery and provisioning an application password all require an explicit confirmation, so Claude cannot do them on its own initiative. Secrets never travel: vault credentials are returned as names and masked hints, and a webhook’s stored authorization header is redacted before it leaves your site. ## If sign-in fails at the last step Two things account for almost every failure, and both are your host rather than the plugins. First, many servers block or intercept `/.well-known/` paths — a common nginx rule denies anything containing a dot-directory, which silently swallows OAuth discovery. It needs an explicit exception that lets WordPress answer those URLs. Second, some firewalls block the request Claude makes to register itself, usually by filtering on its user agent. If discovery succeeds but the connection still fails, that is the one to ask your host about. On managed hosting where you cannot change either, use \[Claude Code or Cursor\](/docs/connect-claude-code-and-cursor/) instead — that route needs neither OAuth nor `.well-known`. ## A note on the extra plugin Enable Abilities for MCP is a third-party plugin, not ours, and the OAuth layer inside it comes from the `wp-media/mcp-oauth` library. We document it because it is what genuinely works today, not because Webhook Actions depends on it — our abilities are published through WordPress core’s Abilities API, and any MCP server can drive them. The copy of the MCP Adapter it bundles is a version behind the standalone one; if you would rather run the current adapter, install [that plugin](https://github.com/WordPress/mcp-adapter) as well and the newer code takes over. / Setup 1. 1 **Install Enable Abilities for MCP and turn OAuth on** From Plugins → Add Plugin, search "Enable Abilities for MCP", install and activate. Then go to Settings → WP Abilities → Connection and switch on "claude.ai OAuth Custom Connector". That one switch is what starts the MCP server — the plugin carries its own copy of the WordPress MCP Adapter, so you do not need to install that separately for this route. The same panel shows the exact URL to paste into Claude, with a Copy button; use that rather than typing it. The plugin also registers 91 content-management abilities of its own under the Abilities tab: leave them on if you want them, or switch them off so Claude sees only your webhook tools. 2. 2 **Check the URLs actually answer** Two quick checks in a browser. Open https://your-site.com/.well-known/oauth-authorization-server — you should get a short block of JSON, not a 403 or a 404 page. Then open https://your-site.com/wp-json/ and confirm it returns JSON too; if it redirects or 404s, switch Settings → Permalinks off "Plain" and save. Both have to work before Claude can connect, and both fail in ways that look like a broken plugin rather than a server setting. 3. 3 **Add the connector in Claude** In claude.ai go to Settings → Connectors → Add custom connector. Give it a name and paste the URL you copied in the previous step — it looks like https://your-site.com/wp-json/mcp/mcp-oauth-server 4. 4 **Accept the detected settings** Claude inspects the server and pre-selects the right options, each marked Detected: Authentication "Always required", OAuth client "Use Anthropic’s hosted client metadata", and under Advanced, Transport "Streamable HTTP". Leave all three alone and leave the extra headers empty — the sign-in provides everything. Picking "register one automatically" instead will fail: this server offers no registration endpoint. 5. 5 **Sign in as an administrator** Claude sends you to your own site to log in and approve the connection. Sign in as an administrator: the connection inherits that user’s permissions, and the Webhook Actions abilities require the same capability as the plugin’s own screens. Approving as a lower-privileged user connects successfully but finds no tools, which looks like a broken server. 6. 6 **Set the tool permissions** Claude lists three tools: two read-only ones and execute-ability under "Write/delete tools". Leave the read-only pair on Always allow, and set execute-ability to Needs approval — every action, from reading a log to deleting a webhook, goes through that one tool, so it is the only place you can keep a hand on the wheel. 7. 7 **Ask it something** Try "what webhooks do I have set up?" or "show me which hooks this site fires when an order completes, then wire that to my n8n endpoint and test it". Claude reads your real configuration rather than guessing. / Related [Connect Claude Code or Cursor to your webhooks over MCP](https://wpwebhooks.org/docs/connect-claude-code-and-cursor/)[Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[API Tokens](https://wpwebhooks.org/docs/api-tokens/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Connect Claude on the web to your webhooks — How-to Guide — Webhook Actions Docs","description":"Add your site to claude.ai as a custom connector, so you can ask Claude about your webhooks and have it build them — from the browser, with no terminal.","url":"https://wpwebhooks.org/docs/connect-claude-ai/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Contact Form 7 Integration" description: "Built-in normalization of CF7 submission objects into clean JSON payloads." url: "https://wpwebhooks.org/docs/cf7-integration/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Contact Form 7 Integration Feature since v1.5.0 # Contact Form 7 Integration Built-in normalization of CF7 submission objects into clean JSON payloads. When the wpcf7\_mail\_sent action fires, CF7 submission objects are automatically converted to a structured array containing form ID, title, all field values, and request metadata. No custom hook code required — configure a webhook with the CF7 trigger and the payload is ready to send. / Setup 1. 1 **Create a new webhook** Go to Webhook Actions in the WordPress admin and add a new webhook. Give it a name, e.g. "CF7 → n8n". 2. 2 **Select the trigger** Set the WordPress action hook to wpcf7\_mail\_sent. This fires once per successful CF7 form submission. 3. 3 **Set the webhook URL** Paste your endpoint URL (e.g. your n8n webhook URL). The plugin will POST form data here on every submission. 4. 4 **Save and test** Submit your Contact Form 7 form. Check the Event Log in the plugin admin to see delivery status and the payload that was sent. / Demo / Related [IvyForms Integration](https://wpwebhooks.org/docs/ivyforms-integration/)[Any Hook as a Trigger](https://wpwebhooks.org/docs/any-hook-integration/)[fswa\_normalize\_object](https://wpwebhooks.org/docs/fswa-normalize-object/)[fswa\_payload](https://wpwebhooks.org/docs/fswa-payload/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Contact Form 7 Integration — Feature — Webhook Actions Docs","description":"Built-in normalization of CF7 submission objects into clean JSON payloads.","url":"https://wpwebhooks.org/docs/cf7-integration/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "create_chain — AI Ability" description: "Create a webhook chain — Create a named chain that wires 2xx completions of one webhook to downstream webhooks." url: "https://wpwebhooks.org/docs/ability-create-chain/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / create\_chain AI Ability since v2.0.0 # create\_chain Create a webhook chain — Create a named chain that wires 2xx completions of one webhook to downstream webhooks. Create a named chain that wires 2xx completions of one webhook to downstream webhooks. Registered as the WordPress Ability `flowsystems-webhook-actions/create-chain`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "create_chain", "input": { "name": …, "description": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. Runs without a confirmation pause (the overall plan is still user-approved before execution starts). / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | name | param | string | yes | | | description | param | string | — | | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "create_chain", "summary": "…", "input": { "name": "Order pipeline" } } ``` / Related [create\_chain\_link](https://wpwebhooks.org/docs/ability-create-chain-link/)[Webhook Chains](https://wpwebhooks.org/docs/webhook-chains/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"create_chain — AI Ability — Webhook Actions Docs","description":"Create a webhook chain — Create a named chain that wires 2xx completions of one webhook to downstream webhooks.","url":"https://wpwebhooks.org/docs/ability-create-chain/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "create_chain_link — AI Ability" description: "Add a chain link — Add a source→target edge to a chain." url: "https://wpwebhooks.org/docs/ability-create-chain-link/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / create\_chain\_link AI Ability since v2.0.0 # create\_chain\_link Add a chain link — Add a source→target edge to a chain. Add a source→target edge to a chain. Rejected if it would create a cycle across any chain. Registered as the WordPress Ability `flowsystems-webhook-actions/create-chain-link`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "create_chain_link", "input": { "chain_id": …, "source_webhook_id": …, "target_webhook_id": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. Runs without a confirmation pause (the overall plan is still user-approved before execution starts). / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | chain\_id | param | integer | yes | | | source\_webhook\_id | param | integer | yes | | | target\_webhook\_id | param | integer | yes | | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "create_chain_link", "summary": "…", "input": { "chain_id": 1, "source_webhook_id": 12, "target_webhook_id": 15 } } ``` / Related [create\_chain](https://wpwebhooks.org/docs/ability-create-chain/)[Webhook Chains](https://wpwebhooks.org/docs/webhook-chains/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"create_chain_link — AI Ability — Webhook Actions Docs","description":"Add a chain link — Add a source→target edge to a chain.","url":"https://wpwebhooks.org/docs/ability-create-chain-link/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "create_snippet — AI Ability" description: "Create a Code Glue snippet — Create a Code Glue PHP snippet (inert until assigned to a webhook)." url: "https://wpwebhooks.org/docs/ability-create-snippet/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / create\_snippet AI Ability since v1.3.0 # create\_snippet Create a Code Glue snippet — Create a Code Glue PHP snippet (inert until assigned to a webhook). Create a Code Glue PHP snippet (inert until assigned to a webhook). Write plain PHP without a 100 ? \"vip\" : \"standard\";\nreturn $payload;", "tags": [ "orders" ] } } ``` / Related [preview\_snippet](https://wpwebhooks.org/docs/ability-preview-snippet/)[assign\_snippet](https://wpwebhooks.org/docs/ability-assign-snippet/)[Code Glue](https://wpwebhooks.org/docs/code-glue/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"create_snippet — AI Ability — Webhook Actions Docs","description":"Create a Code Glue snippet — Create a Code Glue PHP snippet (inert until assigned to a webhook).","url":"https://wpwebhooks.org/docs/ability-create-snippet/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "create_webhook — AI Ability" description: "Create a webhook — Create a new webhook (always created DISABLED until the user enables it)." url: "https://wpwebhooks.org/docs/ability-create-webhook/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / create\_webhook AI Ability since v2.0.0 # create\_webhook Create a webhook — Create a new webhook (always created DISABLED until the user enables it). Create a new webhook (always created DISABLED until the user enables it). Provide name, endpoint\_url, http\_method and triggers. Registered as the WordPress Ability `flowsystems-webhook-actions/create-webhook`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "create_webhook", "input": { "name": …, "endpoint_url": …, "http_method": …, "triggers": …, "auth_credential_id": …, "custom_headers": …, "url_params": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. Runs without a confirmation pause (the overall plan is still user-approved before execution starts). / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | name | param | string | yes | | | endpoint\_url | param | string | yes | | | http\_method | param | string | — | GET | POST | PUT | PATCH | DELETE | | triggers | param | array | — | | | auth\_credential\_id | param | integer | — | | | custom\_headers | param | object | — | | | url\_params | param | object | — | | | is\_synchronous | param | boolean | — | Delivery mode. false = asynchronous (queued, delivered in the background on the next cron tick, with automatic retries). true = synchronous (delivered inline during the triggering request, so it works without a reliable cron, at the cost of a little latency on that request). Omit to follow the site default. | | retry\_limit | param | integer | — | Delivery attempts before the job permanently fails, 1-100. Omit to inherit the site-wide setting, then the default of 5. | | backoff\_strategy | param | string | — | How the wait between retries grows. Omit to inherit the site-wide setting, then exponential. exponential | linear | fixed | | backoff\_base\_delay | param | integer | — | Seconds the strategy multiplies, 1-86400. Omit to inherit, then 30. | | backoff\_max\_delay | param | integer | — | Ceiling in seconds for exponential backoff, 1-86400. Omit to inherit, then 3600. | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "create_webhook", "summary": "…", "input": { "name": "CF7 → n8n", "endpoint_url": "https://n8n.example.com/webhook/abc123", "http_method": "POST", "triggers": [ "wpcf7_mail_sent" ] } } ``` / Related [enable\_webhook](https://wpwebhooks.org/docs/ability-enable-webhook/)[probe\_endpoint](https://wpwebhooks.org/docs/ability-probe-endpoint/)[set\_mapping](https://wpwebhooks.org/docs/ability-set-mapping/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"create_webhook — AI Ability — Webhook Actions Docs","description":"Create a webhook — Create a new webhook (always created DISABLED until the user enables it).","url":"https://wpwebhooks.org/docs/ability-create-webhook/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Credentials Vault" description: "Store reusable authentication secrets encrypted at rest and reference them from webhooks instead of pasting raw Authorization headers." url: "https://wpwebhooks.org/docs/credentials-vault/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Credentials Vault Feature since v1.15.0 # Credentials Vault Store reusable authentication secrets encrypted at rest and reference them from webhooks instead of pasting raw Authorization headers. Save authentication credentials once — Bearer token, Basic auth (username + password), API key in a custom header, or a raw custom Authorization value — and reference them from any webhook by name. Secrets are encrypted at rest with AES-256-GCM and are write-only over the API: they are never returned to any caller, only a masked hint (e.g. `Bearer ****1234`). The decrypted value is used only at dispatch time to build the outgoing header, and resolved auth headers are redacted in delivery logs. ## Credential types `bearer` → `Authorization: Bearer `. `basic` → `Authorization: Basic base64(user:pass)` (covers WordPress Application Passwords on a target site). `api_key` / `custom` → a named header such as `X-API-Key: `. ## Safe for AI agents A webhook references a credential via `auth_credential_id` (which takes precedence over the legacy plaintext `auth_header`). An `agent`\-scope token can assign credentials to webhooks but can never read a secret value — so AI assistants can manage webhooks without ever seeing your PATs or passwords. ## Encryption key By default the encryption key is generated and stored in the database (zero-config, survives WordPress salt rotation). For stronger protection, define `FSWA_SECRET_KEY` in `wp-config.php` to move the key out of the database. Adding it never breaks existing credentials — a one-click in-app migration re-encrypts everything with the new key and then deletes the database copy. ## REST access Manage credentials via `/wp-json/fswa/v1/credentials` (list, create, update, delete — `full`/`agent` scope), with `GET /credentials/key-status` and `POST /credentials/reencrypt` for the encryption-key migration. Delete is blocked with 409 when a credential is in use unless `force=true` is passed. / Related [API Tokens](https://wpwebhooks.org/docs/api-tokens/)[Custom Request Headers](https://wpwebhooks.org/docs/custom-headers/)[REST API](https://wpwebhooks.org/docs/rest-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Credentials Vault — Feature — Webhook Actions Docs","description":"Store reusable authentication secrets encrypted at rest and reference them from webhooks instead of pasting raw Authorization headers.","url":"https://wpwebhooks.org/docs/credentials-vault/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Custom Request Headers" description: "Add static or payload-resolved key/value headers sent with every delivery." url: "https://wpwebhooks.org/docs/custom-headers/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Custom Request Headers Feature since v1.9.0 # Custom Request Headers Add static or payload-resolved key/value headers sent with every delivery. Define any number of custom headers per webhook. Header values support dot-notation paths that are resolved against the outgoing payload at dispatch time — useful for injecting auth tokens, IDs, or any dynamic value. Custom headers are stored in the delivery log. / Related [fswa\_headers](https://wpwebhooks.org/docs/fswa-headers/)[Configurable HTTP Methods](https://wpwebhooks.org/docs/http-methods/)[URL Query Parameters](https://wpwebhooks.org/docs/url-query-params/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Custom Request Headers — Feature — Webhook Actions Docs","description":"Add static or payload-resolved key/value headers sent with every delivery.","url":"https://wpwebhooks.org/docs/custom-headers/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "delete_snippet — AI Ability" description: "Delete a Code Glue snippet — Permanently delete a Code Glue snippet." url: "https://wpwebhooks.org/docs/ability-delete-snippet/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / delete\_snippet AI Ability since v1.3.0 # delete\_snippet Delete a Code Glue snippet — Permanently delete a Code Glue snippet. Permanently delete a Code Glue snippet. Requires confirmation. Webhooks referencing it fall back to no snippet. Registered as the WordPress Ability `flowsystems-webhook-actions/delete-snippet`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "delete_snippet", "input": { "id": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. This step always pauses for explicit user confirmation before it runs. External MCP/REST callers get a 428 and must re-send the same call with `"confirmed": true`. / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | id | param | integer | yes | | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "delete_snippet", "summary": "…", "input": { "id": 4 } } ``` / Related [list\_snippets](https://wpwebhooks.org/docs/ability-list-snippets/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"delete_snippet — AI Ability — Webhook Actions Docs","description":"Delete a Code Glue snippet — Permanently delete a Code Glue snippet.","url":"https://wpwebhooks.org/docs/ability-delete-snippet/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "delete_webhook — AI Ability" description: "Delete a webhook — Permanently delete a webhook and its triggers / mapping." url: "https://wpwebhooks.org/docs/ability-delete-webhook/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / delete\_webhook AI Ability since v2.0.0 # delete\_webhook Delete a webhook — Permanently delete a webhook and its triggers / mapping. Permanently delete a webhook and its triggers / mapping. Requires confirmation. Registered as the WordPress Ability `flowsystems-webhook-actions/delete-webhook`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "delete_webhook", "input": { "id": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. This step always pauses for explicit user confirmation before it runs. External MCP/REST callers get a 428 and must re-send the same call with `"confirmed": true`. / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | id | param | integer | yes | | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "delete_webhook", "summary": "…", "input": { "id": 12 } } ``` / Related [enable\_webhook](https://wpwebhooks.org/docs/ability-enable-webhook/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"delete_webhook — AI Ability — Webhook Actions Docs","description":"Delete a webhook — Permanently delete a webhook and its triggers / mapping.","url":"https://wpwebhooks.org/docs/ability-delete-webhook/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Delivery Logs" description: "Every delivery attempt is stored with HTTP status, response body, headers sent, and full attempt history." url: "https://wpwebhooks.org/docs/delivery-logs/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Delivery Logs Feature since v1.0.0 # Delivery Logs Every delivery attempt is stored with HTTP status, response body, headers sent, and full attempt history. The plugin stores a log entry for every webhook delivery: HTTP status code, response body, request headers, final resolved URL, duration in milliseconds, and a JSON array of all retry attempts. Logs are browsable and filterable in the admin panel. Each entry links back to its queue job and exposes a replay button. ## Log statuses `pending` → `success` | `failed` (retrying) | `permanently_failed` | `skipped` (condition check failed) | `test`. Condition-skipped events are replayable — useful for testing condition changes against past payloads. ## Filtering Filter by status, webhook, trigger name, event UUID, target URL, or date range. The REST API exposes the same filters programmatically. / Related [Webhook Replay](https://wpwebhooks.org/docs/webhook-replay/)[fswa\_success](https://wpwebhooks.org/docs/fswa-success/)[fswa\_error](https://wpwebhooks.org/docs/fswa-error/)[REST API](https://wpwebhooks.org/docs/rest-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Delivery Logs — Feature — Webhook Actions Docs","description":"Every delivery attempt is stored with HTTP status, response body, headers sent, and full attempt history.","url":"https://wpwebhooks.org/docs/delivery-logs/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Developer Docs — Webhook Actions for WordPress" description: "Reference and how-to docs for the Webhook Actions WordPress plugin — guides, features, AI abilities, filters, and action hooks with code examples." url: "https://wpwebhooks.org/docs/" --- [WP Webhooks](https://wpwebhooks.org/)/ Docs # Developer Docs 101 pages — how-to guides, features, AI abilities, filters, and action hooks. ## / How to How to Install the plugin Install Webhook Actions from the zip this site hands you, from your WordPress dashboard, or with one WP-CLI command. [https://wpwebhooks.org/docs/install-the-plugin/](https://wpwebhooks.org/docs/install-the-plugin/) How to Get a free Google AI Studio API key Get a free Gemini API key from Google AI Studio in about two minutes and connect it to Build with AI — no credit card required. [https://wpwebhooks.org/docs/get-google-ai-studio-api-key/](https://wpwebhooks.org/docs/get-google-ai-studio-api-key/) How to Connect Claude Code or Cursor to your webhooks over MCP Let an AI tool on your machine read your triggers and delivery logs, and build webhooks for you, by connecting it to your site over MCP. [https://wpwebhooks.org/docs/connect-claude-code-and-cursor/](https://wpwebhooks.org/docs/connect-claude-code-and-cursor/) How to Connect Claude on the web to your webhooks Add your site to claude.ai as a custom connector, so you can ask Claude about your webhooks and have it build them — from the browser, with no terminal. [https://wpwebhooks.org/docs/connect-claude-ai/](https://wpwebhooks.org/docs/connect-claude-ai/) ## / Features Feature Action Scheduler Support Automatically uses Action Scheduler (when available) instead of WP-Cron for queue processing. [https://wpwebhooks.org/docs/action-scheduler/](https://wpwebhooks.org/docs/action-scheduler/) Feature Activity History Persistent audit log of every admin and API-token action with actor identity, structured diffs, and AI prompt capture. [https://wpwebhooks.org/docs/activity-history/](https://wpwebhooks.org/docs/activity-history/) Feature AI Dev Trace A diagnostic panel for Build with AI that records the exact prompts, raw requests, and raw responses exchanged with the AI model — API keys always redacted. [https://wpwebhooks.org/docs/ai-dev-trace/](https://wpwebhooks.org/docs/ai-dev-trace/) Feature Any Hook as a Trigger Use any WordPress do\_action hook as a webhook trigger — no built-in integration needed. [https://wpwebhooks.org/docs/any-hook-integration/](https://wpwebhooks.org/docs/any-hook-integration/) Feature API Docs Library A hosted reference for third-party APIs — HubSpot, Airtable, Slack, Notion, Stripe and dozens more — read into every Build with AI turn that targets one, so the request is built from the vendor's current docs instead of the model's memory. [https://wpwebhooks.org/docs/api-docs-library/](https://wpwebhooks.org/docs/api-docs-library/) Feature API Tokens Scoped API tokens for REST API access — create tokens with read, operational, full, or agent scope. [https://wpwebhooks.org/docs/api-tokens/](https://wpwebhooks.org/docs/api-tokens/) Feature Async Delivery Queue Webhook events are written to a persistent queue and delivered outside the request cycle. [https://wpwebhooks.org/docs/async-queue/](https://wpwebhooks.org/docs/async-queue/) Feature Build with AI An in-admin AI agent that turns plain-language requests into working, tested webhook integrations — plan-first, with confirmation before anything goes live. [https://wpwebhooks.org/docs/build-with-ai/](https://wpwebhooks.org/docs/build-with-ai/) Feature Captured payload anonymization Personal data in captured example payloads is replaced with placeholders before a build leaves your site. [https://wpwebhooks.org/docs/payload-anonymization/](https://wpwebhooks.org/docs/payload-anonymization/) Feature Code Glue Attach short PHP snippets per webhook+trigger to enrich payloads before dispatch. [https://wpwebhooks.org/docs/code-glue/](https://wpwebhooks.org/docs/code-glue/) Feature Conditional Dispatch Events are only delivered when payload field values match configured rules; non-matching events are logged as skipped. [https://wpwebhooks.org/docs/conditional-dispatch/](https://wpwebhooks.org/docs/conditional-dispatch/) Feature Configurable HTTP Methods Each webhook can send requests as GET, POST, PUT, PATCH, or DELETE. [https://wpwebhooks.org/docs/http-methods/](https://wpwebhooks.org/docs/http-methods/) Feature Contact Form 7 Integration Built-in normalization of CF7 submission objects into clean JSON payloads. [https://wpwebhooks.org/docs/cf7-integration/](https://wpwebhooks.org/docs/cf7-integration/) Feature Credentials Vault Store reusable authentication secrets encrypted at rest and reference them from webhooks instead of pasting raw Authorization headers. [https://wpwebhooks.org/docs/credentials-vault/](https://wpwebhooks.org/docs/credentials-vault/) Feature Custom Request Headers Add static or payload-resolved key/value headers sent with every delivery. [https://wpwebhooks.org/docs/custom-headers/](https://wpwebhooks.org/docs/custom-headers/) Feature Delivery Logs Every delivery attempt is stored with HTTP status, response body, headers sent, and full attempt history. [https://wpwebhooks.org/docs/delivery-logs/](https://wpwebhooks.org/docs/delivery-logs/) Feature Developer Extensibility 16 filters and 6 action hooks let you customize every aspect of dispatch, payload, headers, and retry behavior. [https://wpwebhooks.org/docs/developer-extensibility/](https://wpwebhooks.org/docs/developer-extensibility/) Feature Dynamic URL Templates Use \`{{ field.path }}\` placeholders in endpoint URLs — resolved per-event against the live payload. [https://wpwebhooks.org/docs/dynamic-url-templates/](https://wpwebhooks.org/docs/dynamic-url-templates/) Feature Dynamic URLs via Filter Rewrite the webhook endpoint URL per-event from PHP using the \`fswa\_webhook\_url\` filter. [https://wpwebhooks.org/docs/dynamic-urls/](https://wpwebhooks.org/docs/dynamic-urls/) Feature Event Identity & Deduplication Headers Every dispatched event gets a UUID and ISO 8601 timestamp embedded in the payload and request headers. [https://wpwebhooks.org/docs/event-identity/](https://wpwebhooks.org/docs/event-identity/) Feature Pro External Cron Replace unreliable visitor-triggered WP-Cron with a fully managed external pinger — no server crontab needed. [https://wpwebhooks.org/docs/external-cron/](https://wpwebhooks.org/docs/external-cron/) Feature Import & Export Move webhooks and chains between sites as a portable JSON document. [https://wpwebhooks.org/docs/import-export/](https://wpwebhooks.org/docs/import-export/) Feature Internationalization (i18n) The entire admin interface and all server-side strings are translatable — English by default, with Polish, Simplified Chinese, and Dutch bundled, plus WPML and Polylang compatibility. [https://wpwebhooks.org/docs/internationalization/](https://wpwebhooks.org/docs/internationalization/) Feature IvyForms Integration Built-in normalization of IvyForms field objects and enrichment of submission payloads. [https://wpwebhooks.org/docs/ivyforms-integration/](https://wpwebhooks.org/docs/ivyforms-integration/) Feature Markdown descriptions Document what each webhook and chain does, in Markdown, with a live preview. [https://wpwebhooks.org/docs/markdown-descriptions/](https://wpwebhooks.org/docs/markdown-descriptions/) Feature Payload Library A hosted reference payload for triggers your site has never fired, so Build with AI can map fields without asking you to fire the event first. [https://wpwebhooks.org/docs/payload-library/](https://wpwebhooks.org/docs/payload-library/) Feature Payload Mapping Rename, restructure, or exclude fields in the outgoing JSON before dispatch using dot-notation paths. [https://wpwebhooks.org/docs/payload-mapping/](https://wpwebhooks.org/docs/payload-mapping/) Feature Per-Webhook Backoff Strategy Choose exponential, linear, or fixed backoff strategy and delay parameters per webhook. [https://wpwebhooks.org/docs/per-webhook-backoff/](https://wpwebhooks.org/docs/per-webhook-backoff/) Feature Per-Webhook Retry Settings Override the global maximum retry attempt count for individual webhooks. [https://wpwebhooks.org/docs/per-webhook-retry/](https://wpwebhooks.org/docs/per-webhook-retry/) Feature Post-Dispatch Scripting Run PHP code after a successful delivery to process the response and write data back to WordPress. [https://wpwebhooks.org/docs/post-dispatch-scripting/](https://wpwebhooks.org/docs/post-dispatch-scripting/) Feature Publish a build Put a build on wpwebhooks.org as a public page others can import — and earn AI credits for it. [https://wpwebhooks.org/docs/publish-a-build/](https://wpwebhooks.org/docs/publish-a-build/) Feature REST API Full operational REST API at \`/wp-json/fswa/v1/\` for managing webhooks, logs, queue, credentials, and tokens. [https://wpwebhooks.org/docs/rest-api/](https://wpwebhooks.org/docs/rest-api/) Feature Share a build Export exactly what one Build with AI conversation created — optionally with the conversation itself. [https://wpwebhooks.org/docs/share-a-build/](https://wpwebhooks.org/docs/share-a-build/) Feature Smart Retry with Exponential Backoff Failed deliveries retry automatically with exponential backoff; 4xx failures are classified as permanent. [https://wpwebhooks.org/docs/smart-retry/](https://wpwebhooks.org/docs/smart-retry/) Feature Synchronous Execution Optionally fire a webhook inline during the WordPress request, bypassing the queue for the first attempt. [https://wpwebhooks.org/docs/synchronous-execution/](https://wpwebhooks.org/docs/synchronous-execution/) Feature Test Delivery Fire a test webhook instantly or via the queue without triggering real WordPress events. [https://wpwebhooks.org/docs/test-delivery/](https://wpwebhooks.org/docs/test-delivery/) Feature Unlimited Conditions Define unlimited condition rules and groups with AND/OR logic between groups. [https://wpwebhooks.org/docs/unlimited-conditions/](https://wpwebhooks.org/docs/unlimited-conditions/) Feature URL Query Parameters Append static or payload-resolved query parameters to the endpoint URL at dispatch time. [https://wpwebhooks.org/docs/url-query-params/](https://wpwebhooks.org/docs/url-query-params/) Feature Webhook Chains A webhook completing with a 2xx response can automatically trigger one or more downstream webhooks. [https://wpwebhooks.org/docs/webhook-chains/](https://wpwebhooks.org/docs/webhook-chains/) Feature Webhook Replay Re-send any logged event — successful, failed, or condition-skipped — using its original payload. [https://wpwebhooks.org/docs/webhook-replay/](https://wpwebhooks.org/docs/webhook-replay/) Feature Pro WP Webhooks AI The hosted model service behind Build with AI — a free 55-credit trial on every install, then a monthly pool of credits with every Pro license. No provider signup, no API keys to paste. [https://wpwebhooks.org/docs/wp-webhooks-ai/](https://wpwebhooks.org/docs/wp-webhooks-ai/) ## / AI Abilities AI Ability assign\_credential Assign a vault credential to a webhook — Reference a stored vault credential from a webhook by id (the secret is injected at dispatch time and never exposed). [https://wpwebhooks.org/docs/ability-assign-credential/](https://wpwebhooks.org/docs/ability-assign-credential/) AI Ability assign\_snippet Assign a Code Glue snippet to a webhook — Attach a snippet to a webhook+trigger as its pre-dispatch (payload transform) or post-dispatch (after-response side effects) Code Glue and enable it. [https://wpwebhooks.org/docs/ability-assign-snippet/](https://wpwebhooks.org/docs/ability-assign-snippet/) AI Ability create\_chain Create a webhook chain — Create a named chain that wires 2xx completions of one webhook to downstream webhooks. [https://wpwebhooks.org/docs/ability-create-chain/](https://wpwebhooks.org/docs/ability-create-chain/) AI Ability create\_chain\_link [Add a chain link — Add a source→target edge to a chain.](https://wpwebhooks.org/docs/ability-create-chain-link/) AI Ability create\_snippet Create a Code Glue snippet — Create a Code Glue PHP snippet (inert until assigned to a webhook). [https://wpwebhooks.org/docs/ability-create-snippet/](https://wpwebhooks.org/docs/ability-create-snippet/) AI Ability create\_webhook Create a webhook — Create a new webhook (always created DISABLED until the user enables it). [https://wpwebhooks.org/docs/ability-create-webhook/](https://wpwebhooks.org/docs/ability-create-webhook/) AI Ability delete\_snippet Delete a Code Glue snippet — Permanently delete a Code Glue snippet. [https://wpwebhooks.org/docs/ability-delete-snippet/](https://wpwebhooks.org/docs/ability-delete-snippet/) AI Ability delete\_webhook Delete a webhook — Permanently delete a webhook and its triggers / mapping. [https://wpwebhooks.org/docs/ability-delete-webhook/](https://wpwebhooks.org/docs/ability-delete-webhook/) AI Ability enable\_webhook Enable / disable a webhook — Enable (go live) or disable a webhook. [https://wpwebhooks.org/docs/ability-enable-webhook/](https://wpwebhooks.org/docs/ability-enable-webhook/) AI Ability get\_logs Get delivery logs — Read recent delivery logs (optionally for one webhook) to verify what was sent and how the endpoint responded. [https://wpwebhooks.org/docs/ability-get-logs/](https://wpwebhooks.org/docs/ability-get-logs/) AI Ability get\_rest\_route\_schema Read a REST route's argument contract on this site — every argument with its type and whether it is required. [https://wpwebhooks.org/docs/ability-get-rest-route-schema/](https://wpwebhooks.org/docs/ability-get-rest-route-schema/) AI Ability get\_snippet Get a Code Glue snippet — Get a single Code Glue snippet by id, including its full PHP code. [https://wpwebhooks.org/docs/ability-get-snippet/](https://wpwebhooks.org/docs/ability-get-snippet/) AI Ability get\_trigger\_schema Get captured payload + mapping for a trigger — Return the last captured example payload, field mapping and conditions for a webhook+trigger so the agent can map against the real payload shape. [https://wpwebhooks.org/docs/ability-get-trigger-schema/](https://wpwebhooks.org/docs/ability-get-trigger-schema/) AI Ability get\_webhook Get a webhook — Get a single webhook by id, including triggers, mapping, conditions, headers and credential assignment. [https://wpwebhooks.org/docs/ability-get-webhook/](https://wpwebhooks.org/docs/ability-get-webhook/) AI Ability list\_credentials List credentials — List vault credentials (names, types and masked hints only — secrets are never returned). [https://wpwebhooks.org/docs/ability-list-credentials/](https://wpwebhooks.org/docs/ability-list-credentials/) AI Ability list\_snippets List Code Glue snippets — List saved Code Glue PHP snippets (id, name, tags — use get\_snippet for the code). [https://wpwebhooks.org/docs/ability-list-snippets/](https://wpwebhooks.org/docs/ability-list-snippets/) AI Ability list\_triggers List available triggers — List every WordPress do\_action hook discovered on this site (runtime + static scan) that can be used as a webhook trigger. [https://wpwebhooks.org/docs/ability-list-triggers/](https://wpwebhooks.org/docs/ability-list-triggers/) AI Ability list\_webhooks List webhooks — List all configured webhooks with their triggers and status. [https://wpwebhooks.org/docs/ability-list-webhooks/](https://wpwebhooks.org/docs/ability-list-webhooks/) AI Ability preview\_snippet Preview (test-run) a Code Glue snippet — Test-run a snippet (by snippet\_id, or raw code) against a real captured payload WITHOUT dispatching anything. [https://wpwebhooks.org/docs/ability-preview-snippet/](https://wpwebhooks.org/docs/ability-preview-snippet/) AI Ability probe\_endpoint Probe a target endpoint — Make a guarded test HTTP call to validate an endpoint before going live. [https://wpwebhooks.org/docs/ability-probe-endpoint/](https://wpwebhooks.org/docs/ability-probe-endpoint/) AI Ability set\_conditions Set conditions — Set conditional-dispatch rules for a webhook+trigger so only matching events leave the site. [https://wpwebhooks.org/docs/ability-set-conditions/](https://wpwebhooks.org/docs/ability-set-conditions/) AI Ability set\_mapping Set field mapping — Set the payload field mapping for a webhook+trigger (rename / restructure / exclude / type-cast fields with dot-notation paths). [https://wpwebhooks.org/docs/ability-set-mapping/](https://wpwebhooks.org/docs/ability-set-mapping/) AI Ability test\_dispatch Test-dispatch a webhook — Send a synchronous test delivery for a webhook using a provided or captured payload, and return the HTTP result so the agent can verify the integration end to end. [https://wpwebhooks.org/docs/ability-test-dispatch/](https://wpwebhooks.org/docs/ability-test-dispatch/) AI Ability update\_snippet Update a Code Glue snippet — Update an existing Code Glue snippet's name, code or tags. [https://wpwebhooks.org/docs/ability-update-snippet/](https://wpwebhooks.org/docs/ability-update-snippet/) AI Ability update\_webhook Update a webhook — Update an existing webhook (endpoint, method, triggers, headers, credential). [https://wpwebhooks.org/docs/ability-update-webhook/](https://wpwebhooks.org/docs/ability-update-webhook/) ## / Filters Filter fswa\_ability\_definitions Filters the ability definitions that make up the AI agent toolset and the WordPress Abilities / MCP exposure. [https://wpwebhooks.org/docs/fswa-ability-definitions/](https://wpwebhooks.org/docs/fswa-ability-definitions/) Filter fswa\_ability\_permitted Filters whether the current request may invoke a Webhook Actions ability through the Abilities API surface. [https://wpwebhooks.org/docs/fswa-ability-permitted/](https://wpwebhooks.org/docs/fswa-ability-permitted/) Filter fswa\_ai\_transport Overrides the LLM transport the Build with AI agent talks through, bypassing built-in provider resolution. [https://wpwebhooks.org/docs/fswa-ai-transport/](https://wpwebhooks.org/docs/fswa-ai-transport/) Filter fswa\_available\_triggers Filters the grouped list of available triggers returned by the triggers REST endpoint. [https://wpwebhooks.org/docs/fswa-available-triggers/](https://wpwebhooks.org/docs/fswa-available-triggers/) Filter fswa\_backoff\_delay Controls the retry delay in seconds between failed delivery attempts. [https://wpwebhooks.org/docs/fswa-backoff-delay/](https://wpwebhooks.org/docs/fswa-backoff-delay/) Filter fswa\_capture\_payload Filters the payload stored as the example capture — does not affect what is dispatched. [https://wpwebhooks.org/docs/fswa-capture-payload/](https://wpwebhooks.org/docs/fswa-capture-payload/) Filter fswa\_headers [Filters the HTTP headers sent with each webhook request.](https://wpwebhooks.org/docs/fswa-headers/) Filter fswa\_http\_args Filters the full \`wp\_remote\_request()\` arguments array before the HTTP call. [https://wpwebhooks.org/docs/fswa-http-args/](https://wpwebhooks.org/docs/fswa-http-args/) Filter fswa\_http\_connect\_timeout Sets the TCP connection timeout in seconds (separate from the read timeout). [https://wpwebhooks.org/docs/fswa-http-connect-timeout/](https://wpwebhooks.org/docs/fswa-http-connect-timeout/) Filter fswa\_http\_timeout [Sets the total HTTP request timeout in seconds.](https://wpwebhooks.org/docs/fswa-http-timeout/) Filter fswa\_max\_attempts Sets the maximum number of delivery attempts before a webhook is marked permanently failed. [https://wpwebhooks.org/docs/fswa-max-attempts/](https://wpwebhooks.org/docs/fswa-max-attempts/) Filter fswa\_normalize\_object Converts custom PHP objects in hook arguments into plain arrays for JSON serialization. [https://wpwebhooks.org/docs/fswa-normalize-object/](https://wpwebhooks.org/docs/fswa-normalize-object/) Filter fswa\_payload Filters the base webhook payload after it is built from the WordPress hook arguments, before it is queued. [https://wpwebhooks.org/docs/fswa-payload/](https://wpwebhooks.org/docs/fswa-payload/) Filter fswa\_queue\_batch\_size [Sets the number of queue jobs processed per batch run.](https://wpwebhooks.org/docs/fswa-queue-batch-size/) Filter fswa\_require\_https [Controls whether webhook endpoint URLs must use HTTPS.](https://wpwebhooks.org/docs/fswa-require-https/) Filter fswa\_should\_dispatch Controls whether a webhook should be dispatched for the current trigger event. [https://wpwebhooks.org/docs/fswa-should-dispatch/](https://wpwebhooks.org/docs/fswa-should-dispatch/) Filter fswa\_webhook\_data Filters webhook data before it is returned in REST API responses. [https://wpwebhooks.org/docs/fswa-webhook-data/](https://wpwebhooks.org/docs/fswa-webhook-data/) Filter fswa\_webhook\_payload Filters the per-webhook outgoing payload after field mapping, immediately before HTTP dispatch. [https://wpwebhooks.org/docs/fswa-webhook-payload/](https://wpwebhooks.org/docs/fswa-webhook-payload/) Filter fswa\_webhook\_url [Rewrites the webhook endpoint URL per-event before dispatch.](https://wpwebhooks.org/docs/fswa-webhook-url/) Filter fswa\_export\_doc Attach top-level blocks to a Build export document before it is serialized. [https://wpwebhooks.org/docs/fswa-export-doc/](https://wpwebhooks.org/docs/fswa-export-doc/) Filter fswa\_export\_trigger Attach per-trigger data to a webhook trigger block during export. [https://wpwebhooks.org/docs/fswa-export-trigger/](https://wpwebhooks.org/docs/fswa-export-trigger/) Filter fswa\_import\_extra\_root\_keys Whitelist additional top-level keys so a Build import document passes strict validation. [https://wpwebhooks.org/docs/fswa-import-extra-root-keys/](https://wpwebhooks.org/docs/fswa-import-extra-root-keys/) ## / Actions Action fswa\_cron\_token\_regenerated Fires immediately after the external cron secret is regenerated. [https://wpwebhooks.org/docs/fswa-cron-token-regenerated/](https://wpwebhooks.org/docs/fswa-cron-token-regenerated/) Action fswa\_error Fires after a webhook delivery fails — covers both HTTP error responses and transport errors. [https://wpwebhooks.org/docs/fswa-error/](https://wpwebhooks.org/docs/fswa-error/) Action fswa\_glue\_post\_dispatch Fires after a successful (2xx) delivery with the full response and both pre/post-mapping payloads. [https://wpwebhooks.org/docs/fswa-glue-post-dispatch/](https://wpwebhooks.org/docs/fswa-glue-post-dispatch/) Action fswa\_glue\_error Fires when an assigned Code Glue snippet errors during a real dispatch (the delivery itself proceeds with the unmodified payload). [https://wpwebhooks.org/docs/fswa-glue-error/](https://wpwebhooks.org/docs/fswa-glue-error/) Action fswa\_skipped Fires when a webhook is skipped because its conditions did not pass. [https://wpwebhooks.org/docs/fswa-skipped/](https://wpwebhooks.org/docs/fswa-skipped/) Action fswa\_success Fires after a webhook is delivered successfully (2xx response received). [https://wpwebhooks.org/docs/fswa-success/](https://wpwebhooks.org/docs/fswa-success/) Action fswa\_webhook\_response Fires after every HTTP response is received — success or error — with full response context. [https://wpwebhooks.org/docs/fswa-webhook-response/](https://wpwebhooks.org/docs/fswa-webhook-response/) Action fswa\_webhook\_saved Fires after a webhook is created or updated via the REST API. [https://wpwebhooks.org/docs/fswa-webhook-saved/](https://wpwebhooks.org/docs/fswa-webhook-saved/) Action fswa\_import\_trigger Restore per-trigger data after a webhook is recreated from a Build import. [https://wpwebhooks.org/docs/fswa-import-trigger/](https://wpwebhooks.org/docs/fswa-import-trigger/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Developer Docs — Webhook Actions for WordPress","description":"Developer reference for the Webhook Actions WordPress plugin — 101 pages covering how-to guides, features, AI abilities, filters, and action hooks with code examples.","url":"https://wpwebhooks.org/docs/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Developer Extensibility" description: "16 filters and 6 action hooks let you customize every aspect of dispatch, payload, headers, and retry behavior." url: "https://wpwebhooks.org/docs/developer-extensibility/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Developer Extensibility Feature since v1.0.0 # Developer Extensibility 16 filters and 6 action hooks let you customize every aspect of dispatch, payload, headers, and retry behavior. The plugin's internal dispatch pipeline is fully extensible via WordPress filters and actions. Customize dispatch conditions, payload shape, request headers, URL rewriting, HTTP timeouts and arguments, retry count and delay, queue batch size, trigger discovery, and webhook data serialization. ## Filters `fswa_should_dispatch`, `fswa_payload`, `fswa_webhook_payload`, `fswa_webhook_url`, `fswa_headers`, `fswa_http_args`, `fswa_http_timeout`, `fswa_http_connect_timeout`, `fswa_max_attempts`, `fswa_backoff_delay`, `fswa_queue_batch_size`, `fswa_require_https`, `fswa_capture_payload`, `fswa_normalize_object`, `fswa_available_triggers`, `fswa_webhook_data`. ## Actions `fswa_success`, `fswa_error`, `fswa_skipped`, `fswa_webhook_response`, `fswa_glue_post_dispatch`, `fswa_webhook_saved`. / Related [fswa\_should\_dispatch](https://wpwebhooks.org/docs/fswa-should-dispatch/)[fswa\_payload](https://wpwebhooks.org/docs/fswa-payload/)[fswa\_webhook\_payload](https://wpwebhooks.org/docs/fswa-webhook-payload/)[fswa\_headers](https://wpwebhooks.org/docs/fswa-headers/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Developer Extensibility — Feature — Webhook Actions Docs","description":"16 filters and 6 action hooks let you customize every aspect of dispatch, payload, headers, and retry behavior.","url":"https://wpwebhooks.org/docs/developer-extensibility/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Dynamic URL Templates" description: "Use `{{ field.path }}` placeholders in endpoint URLs — resolved per-event against the live payload." url: "https://wpwebhooks.org/docs/dynamic-url-templates/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Dynamic URL Templates Feature since v1.7.0 # Dynamic URL Templates Use \`{{ field.path }}\` placeholders in endpoint URLs — resolved per-event against the live payload. Add `{{ field.path }}` tokens anywhere in the webhook URL. At dispatch time, each token is resolved against the outgoing (post-mapping) payload using dot-notation. If the token is not found in the mapped payload, the original pre-mapping payload is consulted as fallback. Values are `rawurlencode()`'d before substitution. ## Example URLs `https://api.hubapi.com/crm/v3/objects/deals/{{ _hs_deal_id }}` — injects the deal ID per event. `https://api.example.com/users/{{ user.id }}/orders/{{ order.id }}` — multiple tokens resolved independently. ## Helper keys that never reach the vendor Since 3.2.0 a top-level payload key that starts with `__` is treated as feed-stock for the URL and headers only: it is resolved into the template and then stripped from the body right before the request is sent. Build with AI uses the convention when it writes a dynamic call — `https://api.github.com/repos/{{ __repo }}/issues` with a Code Glue snippet that sets `$payload['__repo']` from post meta — and a template saved through the agent keeps its braces, which an earlier version of the AI write path stripped. / Related [Dynamic URLs via Filter](https://wpwebhooks.org/docs/dynamic-urls/)[fswa\_webhook\_url](https://wpwebhooks.org/docs/fswa-webhook-url/)[Code Glue](https://wpwebhooks.org/docs/code-glue/)[API Docs Library](https://wpwebhooks.org/docs/api-docs-library/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Dynamic URL Templates — Feature — Webhook Actions Docs","description":"Use `{{ field.path }}` placeholders in endpoint URLs — resolved per-event against the live payload.","url":"https://wpwebhooks.org/docs/dynamic-url-templates/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Dynamic URLs via Filter" description: "Rewrite the webhook endpoint URL per-event from PHP using the `fswa_webhook_url` filter." url: "https://wpwebhooks.org/docs/dynamic-urls/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Dynamic URLs via Filter Feature since v1.0.0 # Dynamic URLs via Filter Rewrite the webhook endpoint URL per-event from PHP using the \`fswa\_webhook\_url\` filter. The `fswa_webhook_url` filter receives the configured URL, the outgoing payload, the webhook configuration, the trigger name, and the original pre-mapping payload. Return a modified URL to route different events to different endpoints — useful for REST APIs that embed object IDs in the path. / Examples Route to a specific HubSpot deal by ID ``` add_filter( 'fswa_webhook_url', function ( $url, $payload, $webhook, $trigger, $original ) { if ( (int) $webhook['id'] === 30 ) { $deal_id = $payload['_hs_deal_id'] ?? ''; return "https://api.hubapi.com/crm/v3/objects/deals/{$deal_id}"; } return $url; }, 10, 5 ); ``` / Related [fswa\_webhook\_url](https://wpwebhooks.org/docs/fswa-webhook-url/)[Dynamic URL Templates](https://wpwebhooks.org/docs/dynamic-url-templates/)[URL Query Parameters](https://wpwebhooks.org/docs/url-query-params/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Dynamic URLs via Filter — Feature — Webhook Actions Docs","description":"Rewrite the webhook endpoint URL per-event from PHP using the `fswa_webhook_url` filter.","url":"https://wpwebhooks.org/docs/dynamic-urls/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "enable_webhook — AI Ability" description: "Enable / disable a webhook — Enable (go live) or disable a webhook." url: "https://wpwebhooks.org/docs/ability-enable-webhook/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / enable\_webhook AI Ability since v2.0.0 # enable\_webhook Enable / disable a webhook — Enable (go live) or disable a webhook. Enable (go live) or disable a webhook. Enabling requires confirmation. Registered as the WordPress Ability `flowsystems-webhook-actions/enable-webhook`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "enable_webhook", "input": { "id": …, "enabled": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. This step always pauses for explicit user confirmation before it runs. External MCP/REST callers get a 428 and must re-send the same call with `"confirmed": true`. / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | id | param | integer | yes | | | enabled | param | boolean | — | | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "enable_webhook", "summary": "…", "input": { "id": 12, "enabled": true } } ``` / Related [create\_webhook](https://wpwebhooks.org/docs/ability-create-webhook/)[delete\_webhook](https://wpwebhooks.org/docs/ability-delete-webhook/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"enable_webhook — AI Ability — Webhook Actions Docs","description":"Enable / disable a webhook — Enable (go live) or disable a webhook.","url":"https://wpwebhooks.org/docs/ability-enable-webhook/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Event Identity & Deduplication Headers" description: "Every dispatched event gets a UUID and ISO 8601 timestamp embedded in the payload and request headers." url: "https://wpwebhooks.org/docs/event-identity/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Event Identity & Deduplication Headers Feature since v1.1.0 # Event Identity & Deduplication Headers Every dispatched event gets a UUID and ISO 8601 timestamp embedded in the payload and request headers. At capture time, each event is assigned a v4 UUID (`event.id`) and a UTC timestamp (`event.timestamp`). These are embedded in the payload under the `event` key and sent as `X-Event-Id` and `X-Event-Timestamp` headers on every delivery attempt — including retries. `X-Webhook-Id` carries the webhook's own stable UUID to identify the source configuration. ## Payload structure The default payload shape includes: `event.id` (UUID), `event.timestamp` (ISO 8601 UTC), `event.version` (plugin schema version), `hook` (trigger name), `args` (raw WordPress hook arguments), `timestamp` (Unix epoch), and `site` (site URL and name). / Related [fswa\_payload](https://wpwebhooks.org/docs/fswa-payload/)[Payload Mapping](https://wpwebhooks.org/docs/payload-mapping/)[Webhook Replay](https://wpwebhooks.org/docs/webhook-replay/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Event Identity & Deduplication Headers — Feature — Webhook Actions Docs","description":"Every dispatched event gets a UUID and ISO 8601 timestamp embedded in the payload and request headers.","url":"https://wpwebhooks.org/docs/event-identity/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "External Cron" description: "Replace unreliable visitor-triggered WP-Cron with a fully managed external pinger — no server crontab needed." url: "https://wpwebhooks.org/docs/external-cron/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / External Cron Feature Pro since v1.14.0 # External Cron Replace unreliable visitor-triggered WP-Cron with a fully managed external pinger — no server crontab needed. WP-Cron fires only when someone visits the site — on low-traffic or idle sites, queued webhooks can sit unprocessed for hours. External Cron replaces this with a managed external worker (powered by Uptime Kuma) that pings your site on a fixed schedule, independently of visitor traffic. The worker is created and maintained automatically through the plugin; there is nothing to configure on your server and no external dashboard to log in to. ## Plugin queue endpoint (min 20 s interval) Pings the plugin's own REST endpoint — /wp-json/fswa/v1/cron/process?token=… — which processes only the webhook delivery queue. Because it does minimal work it can fire as often as every 20 seconds. You also control the batch size (1–100 jobs per run, default 10) to tune throughput without overloading the server. ## WP-Cron endpoint (min 60 s interval) Pings /wp-cron.php?doing\_wp\_cron, running the full WordPress scheduler — all registered cron events including the webhook queue. Choose this if you want one external pinger to cover all WordPress background work. Switching to this mode automatically writes DISABLE\_WP\_CRON to wp-config.php so WordPress stops spawning its own background requests; switching back removes it. ## Fully managed — zero server setup On Pro license activation an external worker is provisioned automatically and starts pinging immediately. The target URL updates itself if you regenerate the cron token or change the mode. The worker is paused automatically when the license is deactivated and resumed when it is reactivated. No crontab, no server access, no external accounts — everything is controlled from the plugin settings. ## Live heartbeat history in wp-admin The External Cron tab shows a heartbeat chart with 24 h uptime and average response time. If the last ping failed, the error message is surfaced directly in wp-admin. You can pause and resume the worker from the same screen — no external service dashboard required. / Related [Async Delivery Queue](https://wpwebhooks.org/docs/async-queue/)[Smart Retry with Exponential Backoff](https://wpwebhooks.org/docs/smart-retry/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"External Cron — Feature — Webhook Actions Docs","description":"Replace unreliable visitor-triggered WP-Cron with a fully managed external pinger — no server crontab needed.","url":"https://wpwebhooks.org/docs/external-cron/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_ability_definitions — Filter Hook" description: "Filters the ability definitions that make up the AI agent toolset and the WordPress Abilities / MCP exposure." url: "https://wpwebhooks.org/docs/fswa-ability-definitions/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_ability\_definitions Filter since v2.0.0 # fswa\_ability\_definitions Filters the ability definitions that make up the AI agent toolset and the WordPress Abilities / MCP exposure. / Signature ``` apply_filters( 'fswa_ability_definitions', $definitions ) ``` / When it fires Fires when the ability registry assembles its definitions — both for the in-admin Build with AI agent and for the `wp_register_ability` / MCP exposure. This is how an extension adds its own abilities to the toolset. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $definitions | param | array | yes | Ability definitions keyed by short name. Each entry: \`label\`, \`description\`, \`category\`, \`scope\` (\`read\`|\`full\`), \`requires\_confirm\` (\`false\`|\`when\_live\`|\`always\`), \`input\_schema\` (JSON Schema), \`callback\`. | / Returns `array` Modified definitions array. Added entries become tools the Build with AI agent can plan with and are registered as WordPress Abilities. / Examples Register a custom ability for the agent ``` add_filter( 'fswa_ability_definitions', function ( $definitions ) { $definitions['flush_cache'] = [ 'label' => 'Flush the site cache', 'description' => 'Flush the object cache after webhook changes.', 'category' => 'maintenance', 'scope' => 'full', 'requires_confirm' => 'always', 'input_schema' => [ 'type' => 'object', 'properties' => new stdClass() ], 'callback' => fn () => [ 'flushed' => wp_cache_flush() ], ]; return $definitions; } ); ``` / Related [Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[Developer Extensibility](https://wpwebhooks.org/docs/developer-extensibility/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_ability_definitions — Filter Hook — Webhook Actions Docs","description":"Filters the ability definitions that make up the AI agent toolset and the WordPress Abilities / MCP exposure.","url":"https://wpwebhooks.org/docs/fswa-ability-definitions/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_ability_permitted — Filter Hook" description: "Filters whether the current request may invoke a Webhook Actions ability through the Abilities API surface." url: "https://wpwebhooks.org/docs/fswa-ability-permitted/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_ability\_permitted Filter since v2.0.0 # fswa\_ability\_permitted Filters whether the current request may invoke a Webhook Actions ability through the Abilities API surface. / Signature ``` apply_filters( 'fswa_ability_permitted', $allowed, $scope ) ``` / When it fires Fires as the permission callback whenever an ability is invoked through the WordPress Abilities API (including MCP adapters). The in-admin Build with AI panel uses its own admin gate and does not pass through this filter. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $allowed | param | bool | yes | Whether access is granted. Default: current user has \`manage\_options\`. | | $scope | param | string | yes | Required registry scope for the ability: \`read\` or \`full\`. | / Returns `bool` Whether the ability may run for this request. / Examples Allow editors to run read-scope abilities ``` add_filter( 'fswa_ability_permitted', function ( $allowed, $scope ) { if ( 'read' === $scope && current_user_can( 'edit_others_posts' ) ) { return true; } return $allowed; }, 10, 2 ); ``` / Related [Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[fswa\_ability\_definitions](https://wpwebhooks.org/docs/fswa-ability-definitions/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_ability_permitted — Filter Hook — Webhook Actions Docs","description":"Filters whether the current request may invoke a Webhook Actions ability through the Abilities API surface.","url":"https://wpwebhooks.org/docs/fswa-ability-permitted/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_ai_transport — Filter Hook" description: "Overrides the LLM transport the Build with AI agent talks through, bypassing built-in provider resolution." url: "https://wpwebhooks.org/docs/fswa-ai-transport/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_ai\_transport Filter since v2.0.0 # fswa\_ai\_transport Overrides the LLM transport the Build with AI agent talks through, bypassing built-in provider resolution. / Signature ``` apply_filters( 'fswa_ai_transport', $transport ) ``` / When it fires Fires first in transport resolution, before the WordPress AI Client and stored provider keys are considered. Useful for routing the agent through a proxy or a custom backend without storing keys in WordPress. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $transport | param | LlmTransportInterface|null | yes | Transport override. Default \`null\` (built-in resolution runs). | / Returns `LlmTransportInterface|null` An object implementing \`LlmTransportInterface\` to route all agent LLM calls through it, or \`null\` to fall back to the WordPress AI Client / bring-your-own-key providers. / Examples Route agent calls through a custom transport ``` add_filter( 'fswa_ai_transport', function ( $transport ) { return new My_Proxy_Transport(); // implements LlmTransportInterface } ); ``` / Related [Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[Credentials Vault](https://wpwebhooks.org/docs/credentials-vault/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_ai_transport — Filter Hook — Webhook Actions Docs","description":"Overrides the LLM transport the Build with AI agent talks through, bypassing built-in provider resolution.","url":"https://wpwebhooks.org/docs/fswa-ai-transport/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_available_triggers — Filter Hook" description: "Filters the grouped list of available triggers returned by the triggers REST endpoint." url: "https://wpwebhooks.org/docs/fswa-available-triggers/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_available\_triggers Filter since v1.0.0 # fswa\_available\_triggers Filters the grouped list of available triggers returned by the triggers REST endpoint. / Signature ``` apply_filters( 'fswa_available_triggers', $triggers ) ``` / When it fires Fires when the REST API returns the available triggers list (used to populate the trigger picker in the admin UI). / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $triggers | param | array | yes | Grouped triggers: \`\[ category => \[ hookName, ... \] \]\`. | / Returns `array` Modified triggers array. Same structure. / Examples Add a custom plugin hook to the trigger list ``` add_filter( 'fswa_available_triggers', function ( $triggers ) { $triggers['My Plugin'][] = 'my_plugin_order_confirmed'; $triggers['My Plugin'][] = 'my_plugin_subscription_renewed'; return $triggers; } ); ``` / Related [Developer Extensibility](https://wpwebhooks.org/docs/developer-extensibility/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_available_triggers — Filter Hook — Webhook Actions Docs","description":"Filters the grouped list of available triggers returned by the triggers REST endpoint.","url":"https://wpwebhooks.org/docs/fswa-available-triggers/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_backoff_delay — Filter Hook" description: "Controls the retry delay in seconds between failed delivery attempts." url: "https://wpwebhooks.org/docs/fswa-backoff-delay/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_backoff\_delay Filter since v1.1.0 # fswa\_backoff\_delay Controls the retry delay in seconds between failed delivery attempts. / Signature ``` apply_filters( 'fswa_backoff_delay', $delay, $attempt, $webhook_id ) ``` / When it fires Fires after each failed delivery attempt when scheduling the next retry. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $delay | param | int | yes | Calculated delay in seconds. Default: \`min(2^attempt × 30, 3600)\`. | | $attempt | param | int | yes | Current attempt number (1-based). | | $webhook\_id | param | int | yes | Webhook configuration ID. | / Returns `int` Delay in seconds before the next attempt is scheduled. / Examples Use fixed 60-second delay for all retries ``` add_filter( 'fswa_backoff_delay', function ( $delay, $attempt, $webhook_id ) { return 60; }, 10, 3 ); ``` / Related [fswa\_max\_attempts](https://wpwebhooks.org/docs/fswa-max-attempts/)[Smart Retry with Exponential Backoff](https://wpwebhooks.org/docs/smart-retry/)[Per-Webhook Backoff Strategy](https://wpwebhooks.org/docs/per-webhook-backoff/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_backoff_delay — Filter Hook — Webhook Actions Docs","description":"Controls the retry delay in seconds between failed delivery attempts.","url":"https://wpwebhooks.org/docs/fswa-backoff-delay/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_capture_payload — Filter Hook" description: "Filters the payload stored as the example capture — does not affect what is dispatched." url: "https://wpwebhooks.org/docs/fswa-capture-payload/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_capture\_payload Filter since v1.9.0 # fswa\_capture\_payload Filters the payload stored as the example capture — does not affect what is dispatched. / Signature ``` apply_filters( 'fswa_capture_payload', $payload, $webhook_id, $trigger ) ``` / When it fires Fires when storing an example payload for use in the field selector and payload mapping UI. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $payload | param | array | yes | Payload to be stored as the captured example. | | $webhook\_id | param | int | yes | Webhook configuration ID. | | $trigger | param | string | yes | WordPress action name. | / Returns `array` Modified capture payload. Does not affect the dispatched payload. / Examples Strip sensitive data from stored captures ``` add_filter( 'fswa_capture_payload', function ( $payload, $webhook_id, $trigger ) { unset( $payload['args'][0]['billing_email'] ); unset( $payload['args'][0]['billing_phone'] ); return $payload; }, 10, 3 ); ``` / Related [Payload Mapping](https://wpwebhooks.org/docs/payload-mapping/)[fswa\_payload](https://wpwebhooks.org/docs/fswa-payload/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_capture_payload — Filter Hook — Webhook Actions Docs","description":"Filters the payload stored as the example capture — does not affect what is dispatched.","url":"https://wpwebhooks.org/docs/fswa-capture-payload/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_cron_token_regenerated — Action Hook" description: "Fires immediately after the external cron secret is regenerated." url: "https://wpwebhooks.org/docs/fswa-cron-token-regenerated/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_cron\_token\_regenerated Action since v1.14.0 # fswa\_cron\_token\_regenerated Fires immediately after the external cron secret is regenerated. / Signature ``` do_action( 'fswa_cron_token_regenerated' ) ``` / When it fires Fires inside `DispatcherController::regenerateCronToken()` after the new secret has been written to the database, before the REST response is returned. The Pro plugin hooks into this action to rebuild the Uptime Kuma monitor URL so the next ping uses the updated token. / Examples Sync an external monitoring URL after cron token rotation ``` add_action( 'fswa_cron_token_regenerated', function () { $new_token = get_option( 'fswa_cron_secret' ); $monitor_url = add_query_arg( 'token', $new_token, 'https://monitoring.example.com/ping' ); update_option( 'my_monitor_url', $monitor_url ); } ); ``` / Related [fswa\_glue\_post\_dispatch](https://wpwebhooks.org/docs/fswa-glue-post-dispatch/)[Developer Extensibility](https://wpwebhooks.org/docs/developer-extensibility/)[External Cron](https://wpwebhooks.org/docs/external-cron/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_cron_token_regenerated — Action Hook — Webhook Actions Docs","description":"Fires immediately after the external cron secret is regenerated.","url":"https://wpwebhooks.org/docs/fswa-cron-token-regenerated/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_error — Action Hook" description: "Fires after a webhook delivery fails — covers both HTTP error responses and transport errors." url: "https://wpwebhooks.org/docs/fswa-error/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_error Action since v1.0.0 # fswa\_error Fires after a webhook delivery fails — covers both HTTP error responses and transport errors. / Signature ``` do_action( 'fswa_error', $trigger, $url, $payload, $response, $attempt ) ``` / When it fires Fires on every failed delivery attempt — 4xx, 5xx, and transport errors (connection refused, timeout, SSL). Fires before the retry decision is made. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $trigger | param | string | yes | WordPress action name. | | $url | param | string | yes | Endpoint URL. | | $payload | param | array | yes | The payload that was sent. | | $response | param | array|WP\_Error | yes | \`wp\_remote\_post()\` response or a \`WP\_Error\` for transport failures. | | $attempt | param | int | — | Current attempt number (1-based). | / Examples Alert on permanently failed deliveries ``` add_action( 'fswa_error', function ( $trigger, $url, $payload, $response, $attempt ) { if ( is_wp_error( $response ) ) { $message = $response->get_error_message(); } else { $code = wp_remote_retrieve_response_code( $response ); $message = "HTTP {$code}"; } error_log( "[fswa] Failed attempt {$attempt}: {$trigger} → {$url} ({$message})" ); }, 10, 5 ); ``` / Related [fswa\_success](https://wpwebhooks.org/docs/fswa-success/)[fswa\_webhook\_response](https://wpwebhooks.org/docs/fswa-webhook-response/)[Smart Retry with Exponential Backoff](https://wpwebhooks.org/docs/smart-retry/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_error — Action Hook — Webhook Actions Docs","description":"Fires after a webhook delivery fails — covers both HTTP error responses and transport errors.","url":"https://wpwebhooks.org/docs/fswa-error/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_export_doc — Filter Hook" description: "Attach top-level blocks to a Build export document before it is serialized." url: "https://wpwebhooks.org/docs/fswa-export-doc/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_export\_doc Filter since v2.4.0 # fswa\_export\_doc Attach top-level blocks to a Build export document before it is serialized. / Signature ``` apply_filters( 'fswa_export_doc', $doc, $webhook_ids, $chain_ids, $options ) ``` / When it fires Fires once per export, after webhooks, chains, and credentials are collected and just before the JSON is returned. Pro uses it to bolt on an `ai_build` provenance block (the Build-with-AI transcript) when the exported objects trace back to an AI conversation and the user opted in. Anything you attach here is still subject to the two passes that run last: site-address anonymization, and personal-data redaction of captured values. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $doc | param | array | yes | The full export document (schema version, credentials, webhooks, chains). | | $webhook\_ids | param | int\[\] | yes | Webhook IDs included in this export. | | $chain\_ids | param | int\[\] | yes | Chain IDs included in this export. | | $options | param | array | — | The caller's intent — \`conversation\_id\`, \`include\_ai\_transcript\`, \`anonymize\_site\_url\`, \`keep\_captured\_values\`. Added in 2.5.0; three-argument callbacks keep working. | / Returns `array` The export document, optionally with extra top-level blocks added. / Examples Stamp exports with the source site URL ``` add_filter( 'fswa_export_doc', function ( $doc, $webhook_ids, $chain_ids, $options = array() ) { // Skip the stamp when the user asked for an anonymized build. if ( empty( $options['anonymize_site_url'] ) ) { $doc['exported_from'] = home_url(); } return $doc; }, 10, 4 ); ``` / Related [Import & Export](https://wpwebhooks.org/docs/import-export/)[Share a build](https://wpwebhooks.org/docs/share-a-build/)[Publish a build](https://wpwebhooks.org/docs/publish-a-build/)[fswa\_export\_trigger](https://wpwebhooks.org/docs/fswa-export-trigger/)[fswa\_import\_trigger](https://wpwebhooks.org/docs/fswa-import-trigger/)[fswa\_import\_extra\_root\_keys](https://wpwebhooks.org/docs/fswa-import-extra-root-keys/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_export_doc — Filter Hook — Webhook Actions Docs","description":"Attach top-level blocks to a Build export document before it is serialized.","url":"https://wpwebhooks.org/docs/fswa-export-doc/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_export_trigger — Filter Hook" description: "Attach per-trigger data to a webhook trigger block during export." url: "https://wpwebhooks.org/docs/fswa-export-trigger/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_export\_trigger Filter since v2.4.0 # fswa\_export\_trigger Attach per-trigger data to a webhook trigger block during export. / Signature ``` apply_filters( 'fswa_export_trigger', $doc, $webhook_id, $trigger_name ) ``` / When it fires Fires once per exported trigger. The plugin listens here to serialize the pre/post Code Glue snippets assigned to that webhook+trigger so they travel with the export. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $doc | param | array | yes | The trigger document (name, and schema: field mapping + conditions). | | $webhook\_id | param | int | yes | The webhook being exported. | | $trigger\_name | param | string | yes | The trigger (WordPress action) name. | / Returns `array` The trigger document, optionally enriched (the plugin itself adds a \`code\_glue\` block). / Examples The code\_glue block attached per trigger ``` { "name": "wpcf7_mail_sent", "schema": { "field_mapping": {}, "conditions": null }, "code_glue": { "pre": { "name": "Enrich", "code": "..." }, "post": null } } ``` / Related [Import & Export](https://wpwebhooks.org/docs/import-export/)[fswa\_export\_doc](https://wpwebhooks.org/docs/fswa-export-doc/)[fswa\_import\_trigger](https://wpwebhooks.org/docs/fswa-import-trigger/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_export_trigger — Filter Hook — Webhook Actions Docs","description":"Attach per-trigger data to a webhook trigger block during export.","url":"https://wpwebhooks.org/docs/fswa-export-trigger/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_glue_error — Action Hook" description: "Fires when an assigned Code Glue snippet errors during a real dispatch (the delivery itself proceeds with the unmodified payload)." url: "https://wpwebhooks.org/docs/fswa-glue-error/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_glue\_error Action since v1.4.0 # fswa\_glue\_error Fires when an assigned Code Glue snippet errors during a real dispatch (the delivery itself proceeds with the unmodified payload). / Signature ``` do_action( 'fswa_glue_error', $webhook_id, $trigger, $stage, $snippet_id, $error ) ``` / When it fires A snippet error never breaks the delivery: the snippet runtime catches every Throwable, the payload falls back unmodified, and the error is written to the PHP error log. This hook fires at that moment so you can alert on broken glue instead of discovering it in the logs. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $webhook\_id | param | int | yes | Webhook the snippet is assigned to. | | $trigger | param | string | yes | Trigger being dispatched. | | $stage | param | string | yes | \`pre\` (payload transform) or \`post\` (after the response). | | $snippet\_id | param | int | yes | The failing snippet’s ID. | | $error | param | string | yes | The PHP error message, with the line number when known. | / Examples Email the site admin when production glue starts failing ``` add_action( 'fswa_glue_error', function ( $webhook_id, $trigger, $stage, $snippet_id, $error ) { wp_mail( get_option( 'admin_email' ), sprintf( 'Code Glue error on webhook #%d', $webhook_id ), sprintf( "Snippet #%d (%s stage, trigger %s) failed:\n\n%s", $snippet_id, $stage, $trigger, $error ) ); }, 10, 5 ); ``` / Related [fswa\_glue\_post\_dispatch](https://wpwebhooks.org/docs/fswa-glue-post-dispatch/)[Post-Dispatch Scripting](https://wpwebhooks.org/docs/post-dispatch-scripting/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_glue_error — Action Hook — Webhook Actions Docs","description":"Fires when an assigned Code Glue snippet errors during a real dispatch (the delivery itself proceeds with the unmodified payload).","url":"https://wpwebhooks.org/docs/fswa-glue-error/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_glue_post_dispatch — Action Hook" description: "Fires after a successful (2xx) delivery with the full response and both pre/post-mapping payloads." url: "https://wpwebhooks.org/docs/fswa-glue-post-dispatch/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_glue\_post\_dispatch Action since v1.7.0 # fswa\_glue\_post\_dispatch Fires after a successful (2xx) delivery with the full response and both pre/post-mapping payloads. / Signature ``` do_action( 'fswa_glue_post_dispatch', $webhook_id, $trigger, $response_code, $response_body, $payload, $webhook, $original_payload ) ``` / When it fires Fires only on 2xx responses, after `fswa_webhook_response`. Post-dispatch Code Glue snippets run at priority 10. Webhook chain dispatch runs at priority 20 — custom hooks at priority < 20 run before chained webhooks fire. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $webhook\_id | param | int | yes | Webhook configuration ID. | | $trigger | param | string | yes | WordPress action name. | | $response\_code | param | int | yes | HTTP response status code (always 2xx here). | | $response\_body | param | string | yes | Raw HTTP response body. | | $payload | param | array | yes | The mapped payload that was sent (post-mapping, post-Code Glue). | | $webhook | param | array | yes | Full webhook configuration row. | | $original\_payload | param | array|null | — | Pre-mapping payload, or \`null\` if no mapping was applied. | / Examples Write a returned external ID back to WordPress meta ``` add_action( 'fswa_glue_post_dispatch', function ( $webhook_id, $trigger, $code, $body, $payload, $webhook, $original ) { if ( $webhook_id !== 42 ) return; $data = json_decode( $body, true ); if ( ! isset( $data['id'] ) ) return; $post_id = $original['order_id'] ?? $payload['order_id'] ?? 0; if ( $post_id ) { update_post_meta( (int) $post_id, '_external_id', sanitize_text_field( $data['id'] ) ); } }, 10, 7 ); ``` / Related [fswa\_webhook\_response](https://wpwebhooks.org/docs/fswa-webhook-response/)[Post-Dispatch Scripting](https://wpwebhooks.org/docs/post-dispatch-scripting/)[Webhook Chains](https://wpwebhooks.org/docs/webhook-chains/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_glue_post_dispatch — Action Hook — Webhook Actions Docs","description":"Fires after a successful (2xx) delivery with the full response and both pre/post-mapping payloads.","url":"https://wpwebhooks.org/docs/fswa-glue-post-dispatch/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_headers — Filter Hook" description: "Filters the HTTP headers sent with each webhook request." url: "https://wpwebhooks.org/docs/fswa-headers/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_headers Filter since v1.0.0 # fswa\_headers Filters the HTTP headers sent with each webhook request. / Signature ``` apply_filters( 'fswa_headers', $headers, $webhook, $trigger ) ``` / When it fires Fires after custom headers configured in the admin panel are merged in, just before the HTTP request args are built. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $headers | param | array | yes | Headers array. Always includes \`Content-Type\`, \`X-Event-Id\`, \`X-Event-Timestamp\`, \`X-Webhook-Id\`. | | $webhook | param | array | yes | Full webhook configuration row. | | $trigger | param | string | yes | WordPress action name. | / Returns `array` Modified headers array (key → value string pairs). / Examples Add an HMAC signature header ``` add_filter( 'fswa_headers', function ( $headers, $webhook, $trigger ) { $secret = get_option( 'my_webhook_secret' ); $payload = $headers['X-Event-Id'] ?? ''; $headers['X-Signature'] = 'sha256=' . hash_hmac( 'sha256', $payload, $secret ); return $headers; }, 10, 3 ); ``` / Related [Custom Request Headers](https://wpwebhooks.org/docs/custom-headers/)[fswa\_http\_args](https://wpwebhooks.org/docs/fswa-http-args/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_headers — Filter Hook — Webhook Actions Docs","description":"Filters the HTTP headers sent with each webhook request.","url":"https://wpwebhooks.org/docs/fswa-headers/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_http_args — Filter Hook" description: "Filters the full `wp_remote_request()` arguments array before the HTTP call." url: "https://wpwebhooks.org/docs/fswa-http-args/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_http\_args Filter since v1.0.0 # fswa\_http\_args Filters the full \`wp\_remote\_request()\` arguments array before the HTTP call. / Signature ``` apply_filters( 'fswa_http_args', $args, $url, $payload ) ``` / When it fires Fires immediately before `wp_remote_request()` is called. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $args | param | array | yes | Full \`wp\_remote\_request()\` args including \`method\`, \`headers\`, \`body\`, \`timeout\`, \`sslverify\`. | | $url | param | string | yes | The resolved endpoint URL. | | $payload | param | array | yes | The outgoing payload array. | / Returns `array` Modified \`wp\_remote\_request()\` args. / Examples Disable SSL verification in local development ``` add_filter( 'fswa_http_args', function ( $args, $url, $payload ) { if ( defined( 'WP_LOCAL_DEV' ) && WP_LOCAL_DEV ) { $args['sslverify'] = false; } return $args; }, 10, 3 ); ``` / Related [fswa\_headers](https://wpwebhooks.org/docs/fswa-headers/)[fswa\_http\_timeout](https://wpwebhooks.org/docs/fswa-http-timeout/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_http_args — Filter Hook — Webhook Actions Docs","description":"Filters the full `wp_remote_request()` arguments array before the HTTP call.","url":"https://wpwebhooks.org/docs/fswa-http-args/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_http_connect_timeout — Filter Hook" description: "Sets the TCP connection timeout in seconds (separate from the read timeout)." url: "https://wpwebhooks.org/docs/fswa-http-connect-timeout/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_http\_connect\_timeout Filter since v1.0.0 # fswa\_http\_connect\_timeout Sets the TCP connection timeout in seconds (separate from the read timeout). / Signature ``` apply_filters( 'fswa_http_connect_timeout', $seconds ) ``` / When it fires Fires when building the `wp_remote_request()` arguments for each delivery. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $seconds | param | int | yes | Connection timeout in seconds. Default: \`2\`. | / Returns `int` Connection timeout in seconds. / Related [fswa\_http\_timeout](https://wpwebhooks.org/docs/fswa-http-timeout/)[fswa\_http\_args](https://wpwebhooks.org/docs/fswa-http-args/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_http_connect_timeout — Filter Hook — Webhook Actions Docs","description":"Sets the TCP connection timeout in seconds (separate from the read timeout).","url":"https://wpwebhooks.org/docs/fswa-http-connect-timeout/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_http_timeout — Filter Hook" description: "Sets the total HTTP request timeout in seconds." url: "https://wpwebhooks.org/docs/fswa-http-timeout/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_http\_timeout Filter since v1.0.0 # fswa\_http\_timeout Sets the total HTTP request timeout in seconds. / Signature ``` apply_filters( 'fswa_http_timeout', $seconds ) ``` / When it fires Fires when building the `wp_remote_request()` arguments for each delivery. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $seconds | param | int | yes | Timeout in seconds. Default: \`5\`. | / Returns `int` Timeout in seconds. / Examples Increase timeout for slow external APIs ``` add_filter( 'fswa_http_timeout', function ( $seconds ) { return 15; } ); ``` / Related [fswa\_http\_connect\_timeout](https://wpwebhooks.org/docs/fswa-http-connect-timeout/)[fswa\_http\_args](https://wpwebhooks.org/docs/fswa-http-args/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_http_timeout — Filter Hook — Webhook Actions Docs","description":"Sets the total HTTP request timeout in seconds.","url":"https://wpwebhooks.org/docs/fswa-http-timeout/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_import_extra_root_keys — Filter Hook" description: "Whitelist additional top-level keys so a Build import document passes strict validation." url: "https://wpwebhooks.org/docs/fswa-import-extra-root-keys/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_import\_extra\_root\_keys Filter since v2.4.0 # fswa\_import\_extra\_root\_keys Whitelist additional top-level keys so a Build import document passes strict validation. / Signature ``` apply_filters( 'fswa_import_extra_root_keys', $keys ) ``` / When it fires The importer validates every document against a strict schema and rejects unknown root keys. Return extra key names here when a companion feature attaches its own top-level block on export and needs it tolerated on import. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $keys | param | string\[\] | yes | Extra root-level keys to tolerate (defaults to an empty array). | / Returns `string[]` The list of extra root keys the importer should accept instead of rejecting the document. / Examples Tolerate a custom provenance block on import ``` add_filter( 'fswa_import_extra_root_keys', function ( $keys ) { $keys[] = 'exported_from'; return $keys; } ); ``` / Related [Import & Export](https://wpwebhooks.org/docs/import-export/)[fswa\_export\_doc](https://wpwebhooks.org/docs/fswa-export-doc/)[fswa\_import\_trigger](https://wpwebhooks.org/docs/fswa-import-trigger/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_import_extra_root_keys — Filter Hook — Webhook Actions Docs","description":"Whitelist additional top-level keys so a Build import document passes strict validation.","url":"https://wpwebhooks.org/docs/fswa-import-extra-root-keys/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_import_trigger — Action Hook" description: "Restore per-trigger data after a webhook is recreated from a Build import." url: "https://wpwebhooks.org/docs/fswa-import-trigger/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_import\_trigger Action since v2.4.0 # fswa\_import\_trigger Restore per-trigger data after a webhook is recreated from a Build import. / Signature ``` do_action( 'fswa_import_trigger', $webhook_id, $trigger_name, $trigger ) ``` / When it fires Fires once per trigger while importing a webhook, after its schema (field mapping + conditions) has been written. The plugin listens here to recreate the pre/post Code Glue snippets carried in the trigger’s `code_glue` block. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $webhook\_id | param | int | yes | The freshly created webhook ID. | | $trigger\_name | param | string | yes | The trigger (WordPress action) name. | | $trigger | param | array | yes | The trigger document from the import (name, schema, and any extra blocks such as \`code\_glue\`). | / Examples Log which triggers were restored on import ``` add_action( 'fswa_import_trigger', function ( $webhook_id, $trigger_name, $trigger ) { error_log( sprintf( '[fswa] imported %s onto webhook #%d', $trigger_name, $webhook_id ) ); }, 10, 3 ); ``` / Related [Import & Export](https://wpwebhooks.org/docs/import-export/)[fswa\_export\_trigger](https://wpwebhooks.org/docs/fswa-export-trigger/)[fswa\_export\_doc](https://wpwebhooks.org/docs/fswa-export-doc/)[fswa\_import\_extra\_root\_keys](https://wpwebhooks.org/docs/fswa-import-extra-root-keys/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_import_trigger — Action Hook — Webhook Actions Docs","description":"Restore per-trigger data after a webhook is recreated from a Build import.","url":"https://wpwebhooks.org/docs/fswa-import-trigger/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_max_attempts — Filter Hook" description: "Sets the maximum number of delivery attempts before a webhook is marked permanently failed." url: "https://wpwebhooks.org/docs/fswa-max-attempts/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_max\_attempts Filter since v1.1.0 # fswa\_max\_attempts Sets the maximum number of delivery attempts before a webhook is marked permanently failed. / Signature ``` apply_filters( 'fswa_max_attempts', $attempts, $webhook_id ) ``` / When it fires Fires when a new queue job is created and before each retry to determine if more attempts remain. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $attempts | param | int | yes | Max attempts. Default: \`5\`. Test webhooks always get 1. | | $webhook\_id | param | int | yes | Webhook configuration ID. | / Returns `int` Maximum number of attempts. Minimum: 1. / Examples Give critical webhooks more attempts ``` add_filter( 'fswa_max_attempts', function ( $attempts, $webhook_id ) { $critical = [ 12, 17 ]; // webhook IDs return in_array( $webhook_id, $critical, true ) ? 10 : $attempts; }, 10, 2 ); ``` / Related [fswa\_backoff\_delay](https://wpwebhooks.org/docs/fswa-backoff-delay/)[Smart Retry with Exponential Backoff](https://wpwebhooks.org/docs/smart-retry/)[Per-Webhook Retry Settings](https://wpwebhooks.org/docs/per-webhook-retry/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_max_attempts — Filter Hook — Webhook Actions Docs","description":"Sets the maximum number of delivery attempts before a webhook is marked permanently failed.","url":"https://wpwebhooks.org/docs/fswa-max-attempts/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_normalize_object — Filter Hook" description: "Converts custom PHP objects in hook arguments into plain arrays for JSON serialization." url: "https://wpwebhooks.org/docs/fswa-normalize-object/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_normalize\_object Filter since v1.5.0 # fswa\_normalize\_object Converts custom PHP objects in hook arguments into plain arrays for JSON serialization. / Signature ``` apply_filters( 'fswa_normalize_object', $data, $object, $class_name ) ``` / When it fires Fires for each PHP object encountered when serializing WordPress hook arguments to JSON. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $data | param | mixed | yes | Current normalized value — \`null\` initially. Return an array to replace the object. | | $object | param | object | yes | The PHP object to normalize. | | $class\_name | param | string | yes | Class name of the object (e.g. \`WPCF7\_Submission\`). | / Returns `array|null` Return an array to use as the object's representation. Return \`null\` to skip (plugin's default reflection-based normalization runs). / Examples Normalize a custom form submission object ``` add_filter( 'fswa_normalize_object', function ( $data, $object, $class_name ) { if ( $class_name !== 'My_Form_Submission' ) return $data; return [ 'form_id' => $object->get_form_id(), 'fields' => $object->get_fields(), ]; }, 10, 3 ); ``` / Related [Contact Form 7 Integration](https://wpwebhooks.org/docs/cf7-integration/)[IvyForms Integration](https://wpwebhooks.org/docs/ivyforms-integration/)[fswa\_payload](https://wpwebhooks.org/docs/fswa-payload/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_normalize_object — Filter Hook — Webhook Actions Docs","description":"Converts custom PHP objects in hook arguments into plain arrays for JSON serialization.","url":"https://wpwebhooks.org/docs/fswa-normalize-object/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_payload — Filter Hook" description: "Filters the base webhook payload after it is built from the WordPress hook arguments, before it is queued." url: "https://wpwebhooks.org/docs/fswa-payload/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_payload Filter since v1.0.0 # fswa\_payload Filters the base webhook payload after it is built from the WordPress hook arguments, before it is queued. / Signature ``` apply_filters( 'fswa_payload', $payload, $trigger, $args ) ``` / When it fires Fires once per trigger event, before the payload is queued. Applies to ALL webhooks watching this trigger. For per-webhook transformation use `fswa_webhook_payload` instead. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $payload | param | array | yes | The built payload. Contains \`event\`, \`hook\`, \`args\`, \`timestamp\`, \`site\` keys by default. | | $trigger | param | string | yes | The WordPress action name. | | $args | param | array | yes | Raw WordPress hook arguments. | / Returns `array` Modified payload array. Must remain an array. / Examples Enrich every payload with current user data ``` add_filter( 'fswa_payload', function ( $payload, $trigger, $args ) { $user = wp_get_current_user(); if ( $user->ID ) { $payload['acting_user'] = [ 'id' => $user->ID, 'email' => $user->user_email, 'roles' => $user->roles, ]; } return $payload; }, 10, 3 ); ``` / Related [fswa\_webhook\_payload](https://wpwebhooks.org/docs/fswa-webhook-payload/)[Payload Mapping](https://wpwebhooks.org/docs/payload-mapping/)[Event Identity & Deduplication Headers](https://wpwebhooks.org/docs/event-identity/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_payload — Filter Hook — Webhook Actions Docs","description":"Filters the base webhook payload after it is built from the WordPress hook arguments, before it is queued.","url":"https://wpwebhooks.org/docs/fswa-payload/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_queue_batch_size — Filter Hook" description: "Sets the number of queue jobs processed per batch run." url: "https://wpwebhooks.org/docs/fswa-queue-batch-size/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_queue\_batch\_size Filter since v1.0.0 # fswa\_queue\_batch\_size Sets the number of queue jobs processed per batch run. / Signature ``` apply_filters( 'fswa_queue_batch_size', $size ) ``` / When it fires Fires at the start of each queue processing run. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $size | param | int | yes | Number of jobs per batch. Default: \`10\`. | / Returns `int` Batch size. Minimum: 1. / Examples Process more jobs per batch on a high-volume store ``` add_filter( 'fswa_queue_batch_size', function ( $size ) { return 25; } ); ``` / Related [Async Delivery Queue](https://wpwebhooks.org/docs/async-queue/)[Action Scheduler Support](https://wpwebhooks.org/docs/action-scheduler/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_queue_batch_size — Filter Hook — Webhook Actions Docs","description":"Sets the number of queue jobs processed per batch run.","url":"https://wpwebhooks.org/docs/fswa-queue-batch-size/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_require_https — Filter Hook" description: "Controls whether webhook endpoint URLs must use HTTPS." url: "https://wpwebhooks.org/docs/fswa-require-https/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_require\_https Filter since v1.0.0 # fswa\_require\_https Controls whether webhook endpoint URLs must use HTTPS. / Signature ``` apply_filters( 'fswa_require_https', $require ) ``` / When it fires Fires when validating the endpoint URL before dispatch. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $require | param | bool | yes | Whether HTTPS is required. Default: \`true\`. | / Returns `bool` Return \`false\` to allow HTTP endpoints. / Examples Allow HTTP in development environments ``` add_filter( 'fswa_require_https', function ( $require ) { return ! ( defined( 'WP_LOCAL_DEV' ) && WP_LOCAL_DEV ); } ); ``` / Related [fswa\_http\_args](https://wpwebhooks.org/docs/fswa-http-args/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_require_https — Filter Hook — Webhook Actions Docs","description":"Controls whether webhook endpoint URLs must use HTTPS.","url":"https://wpwebhooks.org/docs/fswa-require-https/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_should_dispatch — Filter Hook" description: "Controls whether a webhook should be dispatched for the current trigger event." url: "https://wpwebhooks.org/docs/fswa-should-dispatch/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_should\_dispatch Filter since v1.0.0 # fswa\_should\_dispatch Controls whether a webhook should be dispatched for the current trigger event. / Signature ``` apply_filters( 'fswa_should_dispatch', $should, $trigger, $args ) ``` / When it fires Fires before any delivery decision is made — before condition evaluation, queue insertion, or HTTP dispatch. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $should | param | bool | yes | Whether dispatch is currently allowed. Return \`false\` to suppress delivery. | | $trigger | param | string | yes | The WordPress action/filter name that fired. | | $args | param | array | yes | Arguments passed to the WordPress hook. | / Returns `bool` Return \`false\` to cancel delivery. Return the original \`$should\` value to allow it. / Examples Skip autosave and revision events ``` add_filter( 'fswa_should_dispatch', function ( $should, $trigger, $args ) { if ( $trigger === 'save_post' ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return false; $post_id = $args[0] ?? 0; if ( wp_is_post_revision( $post_id ) ) return false; } return $should; }, 10, 3 ); ``` / Related [Conditional Dispatch](https://wpwebhooks.org/docs/conditional-dispatch/)[fswa\_payload](https://wpwebhooks.org/docs/fswa-payload/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_should_dispatch — Filter Hook — Webhook Actions Docs","description":"Controls whether a webhook should be dispatched for the current trigger event.","url":"https://wpwebhooks.org/docs/fswa-should-dispatch/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_skipped — Action Hook" description: "Fires when a webhook is skipped because its conditions did not pass." url: "https://wpwebhooks.org/docs/fswa-skipped/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_skipped Action since v1.7.0 # fswa\_skipped Fires when a webhook is skipped because its conditions did not pass. / Signature ``` do_action( 'fswa_skipped', $trigger, $webhook_id, $reason ) ``` / When it fires Fires after condition evaluation fails, before the log entry is written. The event is still logged with a `skipped` status. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $trigger | param | string | yes | WordPress action name. | | $webhook\_id | param | int | yes | Webhook configuration ID. | | $reason | param | array|null | — | The failed condition rule, or \`null\` if the reason is unavailable. | / Examples Count skipped events in a custom stats table ``` add_action( 'fswa_skipped', function ( $trigger, $webhook_id, $reason ) { global $wpdb; $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->prefix}my_skip_stats (webhook_id, trigger_name, skipped_at) VALUES (%d, %s, NOW())", $webhook_id, $trigger ) ); }, 10, 3 ); ``` / Related [Conditional Dispatch](https://wpwebhooks.org/docs/conditional-dispatch/)[fswa\_should\_dispatch](https://wpwebhooks.org/docs/fswa-should-dispatch/)[Webhook Replay](https://wpwebhooks.org/docs/webhook-replay/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_skipped — Action Hook — Webhook Actions Docs","description":"Fires when a webhook is skipped because its conditions did not pass.","url":"https://wpwebhooks.org/docs/fswa-skipped/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_success — Action Hook" description: "Fires after a webhook is delivered successfully (2xx response received)." url: "https://wpwebhooks.org/docs/fswa-success/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_success Action since v1.0.0 # fswa\_success Fires after a webhook is delivered successfully (2xx response received). / Signature ``` do_action( 'fswa_success', $trigger, $url, $payload, $response ) ``` / When it fires Fires once per successful delivery attempt, after a 2xx HTTP status is received and the log entry is written. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $trigger | param | string | yes | The WordPress action name that triggered the delivery. | | $url | param | string | yes | The endpoint URL the payload was sent to. | | $payload | param | array | yes | The final payload that was delivered. | | $response | param | array | yes | \`wp\_remote\_post()\` response array (includes \`response\`, \`headers\`, \`body\`). | / Examples Log successful deliveries to an external monitoring service ``` add_action( 'fswa_success', function ( $trigger, $url, $payload, $response ) { $code = wp_remote_retrieve_response_code( $response ); error_log( "[fswa] Delivered {$trigger} to {$url} — HTTP {$code}" ); }, 10, 4 ); ``` / Related [fswa\_error](https://wpwebhooks.org/docs/fswa-error/)[fswa\_webhook\_response](https://wpwebhooks.org/docs/fswa-webhook-response/)[Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_success — Action Hook — Webhook Actions Docs","description":"Fires after a webhook is delivered successfully (2xx response received).","url":"https://wpwebhooks.org/docs/fswa-success/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_webhook_data — Filter Hook" description: "Filters webhook data before it is returned in REST API responses." url: "https://wpwebhooks.org/docs/fswa-webhook-data/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_webhook\_data Filter since v1.3.0 # fswa\_webhook\_data Filters webhook data before it is returned in REST API responses. / Signature ``` apply_filters( 'fswa_webhook_data', $data, $webhook_id ) ``` / When it fires Fires when a webhook is fetched via the REST API (single webhook or list endpoint). / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $data | param | array | yes | Serialized webhook data array returned by the REST endpoint. | | $webhook\_id | param | int | yes | Webhook configuration ID. | / Returns `array` Modified webhook data. Extensions use this to append their own per-webhook fields. / Examples Append a custom computed field to webhook responses ``` add_filter( 'fswa_webhook_data', function ( $data, $webhook_id ) { $data['delivery_count'] = (int) get_option( "my_webhook_{$webhook_id}_count", 0 ); return $data; }, 10, 2 ); ``` / Related [REST API](https://wpwebhooks.org/docs/rest-api/)[fswa\_webhook\_saved](https://wpwebhooks.org/docs/fswa-webhook-saved/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_webhook_data — Filter Hook — Webhook Actions Docs","description":"Filters webhook data before it is returned in REST API responses.","url":"https://wpwebhooks.org/docs/fswa-webhook-data/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_webhook_payload — Filter Hook" description: "Filters the per-webhook outgoing payload after field mapping, immediately before HTTP dispatch." url: "https://wpwebhooks.org/docs/fswa-webhook-payload/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_webhook\_payload Filter since v1.0.0 # fswa\_webhook\_payload Filters the per-webhook outgoing payload after field mapping, immediately before HTTP dispatch. / Signature ``` apply_filters( 'fswa_webhook_payload', $payload, $webhook_id, $trigger, $original_payload ) ``` / When it fires Fires once per webhook delivery, after field mapping and type casting but before the HTTP request is sent. Also fires during synchronous dispatch and queue job execution. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $payload | param | array | yes | The mapped payload about to be dispatched. | | $webhook\_id | param | int | yes | Webhook configuration ID. | | $trigger | param | string | yes | The WordPress action name. | | $original\_payload | param | array|null | — | Pre-mapping payload, or \`null\` if no mapping was applied. | / Returns `array` Modified payload. Must remain an array. / Examples Inject site URL and current time into every delivery ``` add_filter( 'fswa_webhook_payload', function ( $payload, $webhook_id, $trigger, $original ) { $payload['_meta'] = [ 'site' => home_url(), 'sent_at' => gmdate( 'c' ), 'webhook' => $webhook_id, ]; return $payload; }, 10, 4 ); ``` Modify payload only for a specific webhook ``` add_filter( 'fswa_webhook_payload', function ( $payload, $webhook_id, $trigger, $original ) { if ( $webhook_id !== 42 ) return $payload; // Rename a field for this webhook only $payload['contact_email'] = $payload['billing_email'] ?? ''; unset( $payload['billing_email'] ); return $payload; }, 10, 4 ); ``` / Related [fswa\_payload](https://wpwebhooks.org/docs/fswa-payload/)[Payload Mapping](https://wpwebhooks.org/docs/payload-mapping/)[Code Glue](https://wpwebhooks.org/docs/code-glue/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_webhook_payload — Filter Hook — Webhook Actions Docs","description":"Filters the per-webhook outgoing payload after field mapping, immediately before HTTP dispatch.","url":"https://wpwebhooks.org/docs/fswa-webhook-payload/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_webhook_response — Action Hook" description: "Fires after every HTTP response is received — success or error — with full response context." url: "https://wpwebhooks.org/docs/fswa-webhook-response/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_webhook\_response Action since v1.9.0 # fswa\_webhook\_response Fires after every HTTP response is received — success or error — with full response context. / Signature ``` do_action( 'fswa_webhook_response', $webhook_id, $trigger, $response_code, $response_body, $payload, $webhook ) ``` / When it fires Fires after every HTTP response, for both 2xx successes and error codes. Fires on the first synchronous attempt and on every queue retry. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $webhook\_id | param | int | yes | Webhook configuration ID. | | $trigger | param | string | yes | WordPress action name. | | $response\_code | param | int | yes | HTTP response status code. | | $response\_body | param | string | yes | Raw HTTP response body. | | $payload | param | array | yes | The final payload that was sent. | | $webhook | param | array | yes | Full webhook configuration row. | / Examples Store a returned HubSpot deal ID in post meta ``` add_action( 'fswa_webhook_response', function ( $webhook_id, $trigger, $code, $body, $payload, $webhook ) { if ( $code !== 201 ) return; $data = json_decode( $body, true ); if ( isset( $data['id'], $payload['order_id'] ) ) { update_post_meta( (int) $payload['order_id'], '_hs_deal_id', sanitize_text_field( $data['id'] ) ); } }, 10, 6 ); ``` / Related [fswa\_success](https://wpwebhooks.org/docs/fswa-success/)[fswa\_glue\_post\_dispatch](https://wpwebhooks.org/docs/fswa-glue-post-dispatch/)[Post-Dispatch Scripting](https://wpwebhooks.org/docs/post-dispatch-scripting/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_webhook_response — Action Hook — Webhook Actions Docs","description":"Fires after every HTTP response is received — success or error — with full response context.","url":"https://wpwebhooks.org/docs/fswa-webhook-response/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_webhook_saved — Action Hook" description: "Fires after a webhook is created or updated via the REST API." url: "https://wpwebhooks.org/docs/fswa-webhook-saved/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_webhook\_saved Action since v1.3.0 # fswa\_webhook\_saved Fires after a webhook is created or updated via the REST API. / Signature ``` do_action( 'fswa_webhook_saved', $webhook_id, $webhook_data, $is_new ) ``` / When it fires Fires after the webhook row is written to the database, inside the REST API handler for create and update operations. Extensions use this to persist their own per-webhook fields. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $webhook\_id | param | int | yes | Webhook configuration ID. | | $webhook\_data | param | array | yes | The saved webhook data array. | | $is\_new | param | bool | yes | \`true\` if the webhook was just created; \`false\` on update. | / Examples Log webhook changes to an audit log ``` add_action( 'fswa_webhook_saved', function ( $webhook_id, $webhook_data, $is_new ) { $action = $is_new ? 'created' : 'updated'; $name = $webhook_data['name'] ?? "#{$webhook_id}"; error_log( "[fswa] Webhook {$name} {$action} (ID: {$webhook_id})" ); }, 10, 3 ); ``` / Related [fswa\_webhook\_data](https://wpwebhooks.org/docs/fswa-webhook-data/)[REST API](https://wpwebhooks.org/docs/rest-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_webhook_saved — Action Hook — Webhook Actions Docs","description":"Fires after a webhook is created or updated via the REST API.","url":"https://wpwebhooks.org/docs/fswa-webhook-saved/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "fswa_webhook_url — Filter Hook" description: "Rewrites the webhook endpoint URL per-event before dispatch." url: "https://wpwebhooks.org/docs/fswa-webhook-url/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / fswa\_webhook\_url Filter since v1.0.0 # fswa\_webhook\_url Rewrites the webhook endpoint URL per-event before dispatch. / Signature ``` apply_filters( 'fswa_webhook_url', $url, $payload, $webhook, $trigger, $original_payload ) ``` / When it fires Fires after URL template expansion (`{{ field }}` tokens), just before the HTTP request is built. / Parameters | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | $url | param | string | yes | The configured endpoint URL (may already contain expanded \`{{ }}\` templates). | | $payload | param | array | yes | The final outgoing payload (post-mapping, post-Code Glue). | | $webhook | param | array | yes | Full webhook configuration row. | | $trigger | param | string | yes | WordPress action name. | | $original\_payload | param | array|null | — | Pre-mapping payload as fallback for token resolution. | / Returns `string` The URL to use for this delivery. Must be a valid HTTPS URL (unless \`fswa\_require\_https\` is false). / Examples Inject a record ID from payload into the URL path ``` add_filter( 'fswa_webhook_url', function ( $url, $payload, $webhook, $trigger, $original ) { if ( (int) $webhook['id'] !== 30 ) return $url; $deal_id = $payload['_hs_deal_id'] ?? ''; return "https://api.hubapi.com/crm/v3/objects/deals/{$deal_id}"; }, 10, 5 ); ``` / Related [Dynamic URL Templates](https://wpwebhooks.org/docs/dynamic-url-templates/)[Dynamic URLs via Filter](https://wpwebhooks.org/docs/dynamic-urls/)[fswa\_webhook\_payload](https://wpwebhooks.org/docs/fswa-webhook-payload/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"fswa_webhook_url — Filter Hook — Webhook Actions Docs","description":"Rewrites the webhook endpoint URL per-event before dispatch.","url":"https://wpwebhooks.org/docs/fswa-webhook-url/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Get a free Google AI Studio API key — How-to Guide" description: "Get a free Gemini API key from Google AI Studio in about two minutes and connect it to Build with AI — no credit card required." url: "https://wpwebhooks.org/docs/get-google-ai-studio-api-key/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Get a free Google AI Studio API key How to since v2.0.0 # Get a free Google AI Studio API key Get a free Gemini API key from Google AI Studio in about two minutes and connect it to Build with AI — no credit card required. You do not need a key to start — a fresh install gets a free 55-credit trial on our hosted models, claimed by your first prompt. This guide is for what comes next: keeping Build with AI running for free once the trial is spent, without a Pro license. The fastest free option is a Google AI Studio API key: Google's free tier covers Gemini models like `gemini-flash-latest` and `gemini-3.5-flash` with generous daily limits and no credit card. The same key works in both places you can connect a provider — a WordPress 7.0 AI connector (Settings → Connectors) or pasted straight into the plugin, where it is stored encrypted in your Credentials Vault and never returned over the API. ## Free-tier limits and fallback Google's free tier is rate-limited per minute and per day, and the exact numbers vary per model — check [Google's rate-limit documentation](https://ai.google.dev/gemini-api/docs/rate-limits) for the current limits on `gemini-flash-latest` / `gemini-3.5-flash`. If the agent hits a rate limit mid-build, it automatically falls back to another connected provider (if you've added one) and keeps going. For heavier use, add billing to the Google Cloud project or connect an Anthropic or OpenAI key alongside. ## What the free tier cannot do Two limits are easy to lose an afternoon to, because neither shows up until a call fails. The first: the free tier cannot generate images at all. The image models are listed by the API and look available, but every `generateContent` call to `gemini-2.5-flash-image` or `gemini-3.x-*-image` returns `429 RESOURCE_EXHAUSTED` with quota IDs ending `-FreeTier` — a hard cap of zero, not a rate limit you can wait out. A text model on the same key answers normally, which is the control test that proves it is the tier and not the key. The second: some older models are closed to new accounts. `gemini-2.5-flash` now returns `404 — no longer available to new users` even though it is still in the model list, so if a model 404s, reach for `gemini-flash-latest` rather than assuming the key is broken. Both behaviours verified against a live free-tier key on 2026-08-20. ## Already on WordPress 7.0 with AI connectors? If your site has a provider configured under Settings → Connectors, Build with AI uses it directly through the WordPress AI Client — no key needs to be stored by the plugin at all. The "Auto" source prefers WordPress connectors and falls back to your own keys. / Setup 1. 1 **Open Google AI Studio** Go to aistudio.google.com and sign in with any Google account. No Google Cloud setup is needed up front — AI Studio handles it for you. 2. 2 **Open "Get API key"** Click "Get API key" in AI Studio. This is where Google manages the API keys tied to your account. 3. 3 **Create and copy the key** Click "Create API key". If you have no Google Cloud project yet, AI Studio creates one automatically. Copy the generated key — you'll paste it into WordPress in the next step. 4. 4 **Connect it in Build with AI** In your WordPress admin, open Webhook Actions → Build with AI. In the "Connect an AI provider" card choose Google, paste the key, and click Connect. The key is encrypted into your Credentials Vault — it is never displayed again and never returned over the API. 5. 5 **Pick a Gemini model and build** The model picker is pre-filtered to the Gemini models capable of driving the agent — the flash models are the free-tier sweet spot. Pick one and describe your first integration, e.g. "When a Contact Form 7 form is submitted, send it as JSON to my n8n webhook." / Related [Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[Credentials Vault](https://wpwebhooks.org/docs/credentials-vault/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Get a free Google AI Studio API key — How-to Guide — Webhook Actions Docs","description":"Get a free Gemini API key from Google AI Studio in about two minutes and connect it to Build with AI — no credit card required.","url":"https://wpwebhooks.org/docs/get-google-ai-studio-api-key/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "get_logs — AI Ability" description: "Get delivery logs — Read recent delivery logs (optionally for one webhook) to verify what was sent and how the endpoint responded." url: "https://wpwebhooks.org/docs/ability-get-logs/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / get\_logs AI Ability since v2.0.0 # get\_logs Get delivery logs — Read recent delivery logs (optionally for one webhook) to verify what was sent and how the endpoint responded. Read recent delivery logs (optionally for one webhook) to verify what was sent and how the endpoint responded. Registered as the WordPress Ability `flowsystems-webhook-actions/get-logs`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "get_logs", "input": { "webhook_id": …, "limit": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `read`. Read-only — the agent runs it freely while planning and verifying. / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | webhook\_id | param | integer | — | | | limit | param | integer | — | | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "get_logs", "summary": "…", "input": { "webhook_id": 12, "limit": 20 } } ``` / Related [test\_dispatch](https://wpwebhooks.org/docs/ability-test-dispatch/)[Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"get_logs — AI Ability — Webhook Actions Docs","description":"Get delivery logs — Read recent delivery logs (optionally for one webhook) to verify what was sent and how the endpoint responded.","url":"https://wpwebhooks.org/docs/ability-get-logs/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "get_rest_route_schema — AI Ability" description: "Read a REST route's argument contract on this site — every argument with its type and whether it is required." url: "https://wpwebhooks.org/docs/ability-get-rest-route-schema/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / get\_rest\_route\_schema AI Ability since v2.2.0 # get\_rest\_route\_schema Read a REST route's argument contract on this site — every argument with its type and whether it is required. Describe a REST API route on THIS site (core or any plugin) from its self-declared schema: every argument with its type, description and whether it is REQUIRED. Build with AI runs this before building an internal automation — a webhook whose endpoint points at your own `wp-json` — so required fields (e.g. `POST /wp/v2/users` needs username, email AND password, which field mapping alone cannot always supply) are satisfied with a Code Glue snippet, or called out honestly, instead of guessed. Internal routes only — it cannot describe external APIs. Registered as the WordPress Ability `flowsystems-webhook-actions/get-rest-route-schema`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "get_rest_route_schema", "input": { "route": …, "method": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `read`. Read-only — the agent runs it freely while planning. / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | route | param | string | yes | Route path relative to /wp-json, e.g. "/wp/v2/users". A full URL to this site's REST API is also accepted. | | method | param | string | — | HTTP method you plan to use — GET, POST (default), PUT, PATCH or DELETE. | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "get_rest_route_schema", "summary": "…", "input": { "route": "/wp/v2/users", "method": "POST" } } ``` / Related [get\_trigger\_schema](https://wpwebhooks.org/docs/ability-get-trigger-schema/)[create\_snippet](https://wpwebhooks.org/docs/ability-create-snippet/)[Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"get_rest_route_schema — AI Ability — Webhook Actions Docs","description":"Read a REST route's argument contract on this site — every argument with its type and whether it is required.","url":"https://wpwebhooks.org/docs/ability-get-rest-route-schema/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "get_snippet — AI Ability" description: "Get a Code Glue snippet — Get a single Code Glue snippet by id, including its full PHP code." url: "https://wpwebhooks.org/docs/ability-get-snippet/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / get\_snippet AI Ability since v1.3.0 # get\_snippet Get a Code Glue snippet — Get a single Code Glue snippet by id, including its full PHP code. Get a single Code Glue snippet by id, including its full PHP code. Registered as the WordPress Ability `flowsystems-webhook-actions/get-snippet`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "get_snippet", "input": { "id": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `read`. Read-only — the agent runs it freely while planning and verifying. / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | id | param | integer | yes | Snippet id | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "get_snippet", "summary": "…", "input": { "id": 4 } } ``` / Related [list\_snippets](https://wpwebhooks.org/docs/ability-list-snippets/)[update\_snippet](https://wpwebhooks.org/docs/ability-update-snippet/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"get_snippet — AI Ability — Webhook Actions Docs","description":"Get a Code Glue snippet — Get a single Code Glue snippet by id, including its full PHP code.","url":"https://wpwebhooks.org/docs/ability-get-snippet/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "get_trigger_schema — AI Ability" description: "Get captured payload + mapping for a trigger — Return the last captured example payload, field mapping and conditions for a webhook+trigger so the agent can map against the real payload shape." url: "https://wpwebhooks.org/docs/ability-get-trigger-schema/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / get\_trigger\_schema AI Ability since v2.0.0 # get\_trigger\_schema Get captured payload + mapping for a trigger — Return the last captured example payload, field mapping and conditions for a webhook+trigger so the agent can map against the real payload shape. Return the last captured example payload, field mapping and conditions for a webhook+trigger so the agent can map against the real payload shape. When this site has no capture of its own, the reply can still carry an example from the hosted Payload Library — in which case it also sets `example_source: "library"`, `captured_from` (the plugin build the payload came off), `confidence`, `library_caveat`, `must_verify`, and `site_defined_paths`, the paths under a container this site defines that `set_mapping` and `set_conditions` will refuse. Registered as the WordPress Ability `flowsystems-webhook-actions/get-trigger-schema`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "get_trigger_schema", "input": { "webhook_id": …, "trigger": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `read`. Read-only — the agent runs it freely while planning and verifying. / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | webhook\_id | param | integer | yes | | | trigger | param | string | yes | | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "get_trigger_schema", "summary": "…", "input": { "webhook_id": 12, "trigger": "wpcf7_mail_sent" } } ``` / Related [set\_mapping](https://wpwebhooks.org/docs/ability-set-mapping/)[set\_conditions](https://wpwebhooks.org/docs/ability-set-conditions/)[Payload Mapping](https://wpwebhooks.org/docs/payload-mapping/)[Payload Library](https://wpwebhooks.org/docs/payload-library/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"get_trigger_schema — AI Ability — Webhook Actions Docs","description":"Get captured payload + mapping for a trigger — Return the last captured example payload, field mapping and conditions for a webhook+trigger so the agent can map against the real payload shape.","url":"https://wpwebhooks.org/docs/ability-get-trigger-schema/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "get_webhook — AI Ability" description: "Get a webhook — Get a single webhook by id, including triggers, mapping, conditions, headers and credential assignment." url: "https://wpwebhooks.org/docs/ability-get-webhook/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / get\_webhook AI Ability since v2.0.0 # get\_webhook Get a webhook — Get a single webhook by id, including triggers, mapping, conditions, headers and credential assignment. Get a single webhook by id, including triggers, mapping, conditions, headers and credential assignment. Registered as the WordPress Ability `flowsystems-webhook-actions/get-webhook`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "get_webhook", "input": { "id": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `read`. Read-only — the agent runs it freely while planning and verifying. / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | id | param | integer | yes | Webhook id | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "get_webhook", "summary": "…", "input": { "id": 12 } } ``` / Related [list\_webhooks](https://wpwebhooks.org/docs/ability-list-webhooks/)[update\_webhook](https://wpwebhooks.org/docs/ability-update-webhook/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"get_webhook — AI Ability — Webhook Actions Docs","description":"Get a webhook — Get a single webhook by id, including triggers, mapping, conditions, headers and credential assignment.","url":"https://wpwebhooks.org/docs/ability-get-webhook/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Import & Export" description: "Move webhooks and chains between sites as a portable JSON document." url: "https://wpwebhooks.org/docs/import-export/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Import & Export Feature since v2.4.0 # Import & Export Move webhooks and chains between sites as a portable JSON document. Export any webhook or chain — with its triggers, field mapping, conditions, and (with Pro) its Code Glue snippets — to a single JSON file, then import it into another site. Imports are validated against a strict schema and reported per item, so a malformed or partial document fails safely instead of half-applying. Reusable auth credentials travel as write-only references (never the secret), so nothing sensitive leaves the source site. ## What travels Each exported webhook carries its endpoint, HTTP method, headers, URL params, retry/backoff settings, and every trigger with its field mapping and conditions. Chains carry their links and per-link conditions. With Webhook Actions Pro active, each trigger also carries its pre/post Code Glue snippets via the `fswa_export_trigger` filter, restored on the other side by `fswa_import_trigger`. ## Safe by design The importer rejects unknown top-level keys unless a companion feature opts them in via `fswa_import_extra_root_keys`. Credentials are exported as references with a masked hint only — the encrypted secret is never included, so an imported webhook prompts you to supply or map its credential on the target site. Since 2.5.0 the personal data in captured example payloads is anonymized on the way out, too. ## Importing into a site that already has the build Re-importing is a first-class case, not an edge one. Choose whether duplicates are copied or skipped — either way the chain is rewired correctly, with skipped webhooks mapped to the copies already present. Anything the importer has to leave out (a hop pointing at a webhook that is not in the file, or one that would make a chain loop) is reported back to you rather than dropped quietly. / Related [Share a build](https://wpwebhooks.org/docs/share-a-build/)[Publish a build](https://wpwebhooks.org/docs/publish-a-build/)[Captured payload anonymization](https://wpwebhooks.org/docs/payload-anonymization/)[fswa\_export\_doc](https://wpwebhooks.org/docs/fswa-export-doc/)[fswa\_export\_trigger](https://wpwebhooks.org/docs/fswa-export-trigger/)[fswa\_import\_trigger](https://wpwebhooks.org/docs/fswa-import-trigger/)[Webhook Chains](https://wpwebhooks.org/docs/webhook-chains/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Import & Export — Feature — Webhook Actions Docs","description":"Move webhooks and chains between sites as a portable JSON document.","url":"https://wpwebhooks.org/docs/import-export/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Install the plugin — How-to Guide" description: "Install Webhook Actions from the zip this site hands you, from your WordPress dashboard, or with one WP-CLI command." url: "https://wpwebhooks.org/docs/install-the-plugin/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Install the plugin How to since v1.0.0 # Install the plugin Install Webhook Actions from the zip this site hands you, from your WordPress dashboard, or with one WP-CLI command. Every download button on this site hands you `flowsystems-webhook-actions.zip` straight from WordPress.org — the same file the plugin directory serves, always the current stable release. A downloaded zip is installed by uploading it once in your WordPress admin, which takes about a minute. If you would rather not handle a file at all, WordPress can fetch and install the plugin for you from the same directory listing — both routes end up with the identical plugin and the identical automatic updates. ## Rather not download a file? Go to Plugins → Add Plugin in your admin, search for "Webhook Actions", then click Install Now and Activate on the Flow Systems result. WordPress fetches the same zip from the same place — this route just skips your download folder. ## From the command line With WP-CLI: `wp plugin install flowsystems-webhook-actions --activate`. ## Updates work either way Installing from the zip is not a manual install in the sense that matters — the plugin keeps its WordPress.org slug, so update notices and one-click updates arrive exactly as they would had you installed it from the dashboard. The download is the current stable release, so there is nothing to update on day one. ## Requirements WordPress 6.0 or newer. The free plugin needs nothing else — no account, no API key, no external service. That includes Build with AI: your first prompt claims a free 55-credit trial on our hosted models, and after that you can connect a free Google AI Studio key or Pro credits. / Setup 1. 1 **Download the plugin** Click any Install Plugin button on this site. Your browser downloads the plugin as a .zip file — no WordPress.org account needed. Keep it where you can find it; you upload it once and never need it again. 2. 2 **Upload the zip in WordPress** In your WordPress admin go to Plugins → Add Plugin, click Upload Plugin at the top, choose the zip you just downloaded, and click Install Now. 3. 3 **Activate it** WordPress unpacks the plugin and offers an Activate Plugin button. Click it. Nothing starts sending yet — new webhooks are created disabled, so activating is safe on a live site. 4. 4 **Open Webhook Actions** A Webhook Actions entry appears in the admin sidebar, and it opens on Build with AI. Type what you want built — the first prompt starts your free trial, with no provider to connect first. You can also connect your own provider there, or skip the AI entirely and build webhooks by hand from the Webhooks tab; the plugin does not need AI to work. / Related [Get a free Google AI Studio API key](https://wpwebhooks.org/docs/get-google-ai-studio-api-key/)[Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[Import & Export](https://wpwebhooks.org/docs/import-export/)[Webhook Actions on WordPress.org](https://wordpress.org/plugins/flowsystems-webhook-actions/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Install the plugin — How-to Guide — Webhook Actions Docs","description":"Install Webhook Actions from the zip this site hands you, from your WordPress dashboard, or with one WP-CLI command.","url":"https://wpwebhooks.org/docs/install-the-plugin/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Internationalization (i18n)" description: "The entire admin interface and all server-side strings are translatable — English by default, with Polish, Simplified Chinese, and Dutch bundled, plus WPML and Polylang compatibility." url: "https://wpwebhooks.org/docs/internationalization/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Internationalization (i18n) Feature since v1.16.0 # Internationalization (i18n) The entire admin interface and all server-side strings are translatable — English by default, with Polish, Simplified Chinese, and Dutch bundled, plus WPML and Polylang compatibility. The plugin is fully internationalized: every admin screen, dialog, and inline message, plus all server-side strings, are wrapped for translation. English is the default (source) language; Polish (pl\_PL), Simplified Chinese (zh\_CN), and Dutch (nl\_NL) translations ship bundled and load automatically when WordPress is set to a matching site or user locale. The plugin is compatible with WPML and Polylang String Translation, and free locales can also be contributed through translate.wordpress.org. ## Bundled languages English is the default (source) language. Polish (`pl_PL`), Simplified Chinese (`zh_CN`), and Dutch (`nl_NL`) translations ship bundled with the plugin. WordPress loads the matching set automatically based on the site or per-user locale — no configuration required. ## Translation compatibility Works with WordPress native locale switching, WPML, and Polylang String Translation. Community translations for the free plugin can be contributed through translate.wordpress.org. ## Adding a language Drop a locale `.po`/`.mo`/`.json` set into the plugin's `languages/` directory. The `.pot` template covers all PHP and JavaScript strings — the Vue SPA strings are extracted from source, since `wp i18n make-pot` cannot read `.vue` files. ## Reliable JS translation loading The admin bundle uses a stable, hash-free filename with handle-based script-translation loading, so JavaScript (Vue SPA) translations resolve reliably across plugin updates. / Related [Developer Extensibility](https://wpwebhooks.org/docs/developer-extensibility/)[REST API](https://wpwebhooks.org/docs/rest-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Internationalization (i18n) — Feature — Webhook Actions Docs","description":"The entire admin interface and all server-side strings are translatable — English by default, with Polish, Simplified Chinese, and Dutch bundled, plus WPML and Polylang compatibility.","url":"https://wpwebhooks.org/docs/internationalization/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "IvyForms Integration" description: "Built-in normalization of IvyForms field objects and enrichment of submission payloads." url: "https://wpwebhooks.org/docs/ivyforms-integration/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / IvyForms Integration Feature since v1.6.0 # IvyForms Integration Built-in normalization of IvyForms field objects and enrichment of submission payloads. IvyForms Field objects in submission payloads are automatically normalized into plain arrays. Both the ivyforms/form/before\_submission and ivyforms/form/after\_submission hooks are supported with correct payload enrichment out of the box. No custom hook code required — configure a webhook with either IvyForms trigger and the payload is ready to send. / Setup 1. 1 **Create a new webhook** Go to Webhook Actions in the WordPress admin and add a new webhook. Give it a name, e.g. "IvyForms → n8n". 2. 2 **Select the trigger** Set the WordPress action hook to ivyforms/form/after\_submission. This fires once per successful IvyForms submission. 3. 3 **Set the webhook URL** Paste your endpoint URL (e.g. your n8n webhook URL). The plugin will POST form data here on every submission. 4. 4 **Save and test** Submit your IvyForms form. Check the Event Log in the plugin admin to see delivery status and the full payload that was sent. / Related [Contact Form 7 Integration](https://wpwebhooks.org/docs/cf7-integration/)[Any Hook as a Trigger](https://wpwebhooks.org/docs/any-hook-integration/)[fswa\_normalize\_object](https://wpwebhooks.org/docs/fswa-normalize-object/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"IvyForms Integration — Feature — Webhook Actions Docs","description":"Built-in normalization of IvyForms field objects and enrichment of submission payloads.","url":"https://wpwebhooks.org/docs/ivyforms-integration/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "list_credentials — AI Ability" description: "List credentials — List vault credentials (names, types and masked hints only — secrets are never returned)." url: "https://wpwebhooks.org/docs/ability-list-credentials/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / list\_credentials AI Ability since v2.0.0 # list\_credentials List credentials — List vault credentials (names, types and masked hints only — secrets are never returned). List vault credentials (names, types and masked hints only — secrets are never returned). Registered as the WordPress Ability `flowsystems-webhook-actions/list-credentials`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "list_credentials", "input": { } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. Runs without a confirmation pause (the overall plan is still user-approved before execution starts). / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "list_credentials", "summary": "…", "input": {} } ``` / Related [assign\_credential](https://wpwebhooks.org/docs/ability-assign-credential/)[Credentials Vault](https://wpwebhooks.org/docs/credentials-vault/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"list_credentials — AI Ability — Webhook Actions Docs","description":"List credentials — List vault credentials (names, types and masked hints only — secrets are never returned).","url":"https://wpwebhooks.org/docs/ability-list-credentials/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "list_snippets — AI Ability" description: "List Code Glue snippets — List saved Code Glue PHP snippets (id, name, tags — use get_snippet for the code)." url: "https://wpwebhooks.org/docs/ability-list-snippets/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / list\_snippets AI Ability since v1.3.0 # list\_snippets List Code Glue snippets — List saved Code Glue PHP snippets (id, name, tags — use get\_snippet for the code). List saved Code Glue PHP snippets (id, name, tags — use get\_snippet for the code). Snippets transform the payload before dispatch (pre) or run side effects after the response (post) once assigned to a webhook+trigger. Registered as the WordPress Ability `flowsystems-webhook-actions/list-snippets`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "list_snippets", "input": { "search": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `read`. Read-only — the agent runs it freely while planning and verifying. / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | search | param | string | — | Filter by name or code substring. | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "list_snippets", "summary": "…", "input": { "search": "hubspot" } } ``` / Related [get\_snippet](https://wpwebhooks.org/docs/ability-get-snippet/)[create\_snippet](https://wpwebhooks.org/docs/ability-create-snippet/)[Code Glue](https://wpwebhooks.org/docs/code-glue/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"list_snippets — AI Ability — Webhook Actions Docs","description":"List Code Glue snippets — List saved Code Glue PHP snippets (id, name, tags — use get_snippet for the code).","url":"https://wpwebhooks.org/docs/ability-list-snippets/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "list_triggers — AI Ability" description: "List available triggers — List every WordPress do_action hook discovered on this site (runtime + static scan) that can be used as a webhook trigger." url: "https://wpwebhooks.org/docs/ability-list-triggers/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / list\_triggers AI Ability since v2.0.0 # list\_triggers List available triggers — List every WordPress do\_action hook discovered on this site (runtime + static scan) that can be used as a webhook trigger. List every WordPress do\_action hook discovered on this site (runtime + static scan) that can be used as a webhook trigger. Registered as the WordPress Ability `flowsystems-webhook-actions/list-triggers`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "list_triggers", "input": { } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `read`. Read-only — the agent runs it freely while planning and verifying. / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "list_triggers", "summary": "…", "input": {} } ``` / Related [create\_webhook](https://wpwebhooks.org/docs/ability-create-webhook/)[Any Hook as a Trigger](https://wpwebhooks.org/docs/any-hook-integration/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"list_triggers — AI Ability — Webhook Actions Docs","description":"List available triggers — List every WordPress do_action hook discovered on this site (runtime + static scan) that can be used as a webhook trigger.","url":"https://wpwebhooks.org/docs/ability-list-triggers/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "list_webhooks — AI Ability" description: "List webhooks — List all configured webhooks with their triggers and status." url: "https://wpwebhooks.org/docs/ability-list-webhooks/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / list\_webhooks AI Ability since v2.0.0 # list\_webhooks List webhooks — List all configured webhooks with their triggers and status. List all configured webhooks with their triggers and status. Long descriptions are truncated to a short snippet here and flagged with `description_truncated` — call `get_webhook` for the full text. Registered as the WordPress Ability `flowsystems-webhook-actions/list-webhooks`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "list_webhooks", "input": { } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `read`. Read-only — the agent runs it freely while planning and verifying. / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "list_webhooks", "summary": "…", "input": {} } ``` / Related [get\_webhook](https://wpwebhooks.org/docs/ability-get-webhook/)[update\_webhook](https://wpwebhooks.org/docs/ability-update-webhook/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"list_webhooks — AI Ability — Webhook Actions Docs","description":"List webhooks — List all configured webhooks with their triggers and status.","url":"https://wpwebhooks.org/docs/ability-list-webhooks/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Markdown descriptions" description: "Document what each webhook and chain does, in Markdown, with a live preview." url: "https://wpwebhooks.org/docs/markdown-descriptions/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Markdown descriptions Feature since v2.4.0 # Markdown descriptions Document what each webhook and chain does, in Markdown, with a live preview. Give every webhook and chain a Markdown description so its purpose is clear at a glance in the list and on the edit screen. The description editor has a Write/Preview toggle, so you can check the rendered result — links, lists, emphasis, inline code — while you type, up to a 4000-character limit. ## Where it shows Descriptions render on the Webhooks list under each item and above the chain group, and are editable inline in the webhook form, the chain create form, and the chain rename dialog. They also travel with Import & Export. / Related [Import & Export](https://wpwebhooks.org/docs/import-export/)[Webhook Chains](https://wpwebhooks.org/docs/webhook-chains/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Markdown descriptions — Feature — Webhook Actions Docs","description":"Document what each webhook and chain does, in Markdown, with a live preview.","url":"https://wpwebhooks.org/docs/markdown-descriptions/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Payload Library" description: "A hosted reference payload for triggers your site has never fired, so Build with AI can map fields without asking you to fire the event first." url: "https://wpwebhooks.org/docs/payload-library/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Payload Library Feature since v3.1.0 # Payload Library A hosted reference payload for triggers your site has never fired, so Build with AI can map fields without asking you to fire the event first. Mapping fields needs an example payload, and until a trigger actually fires on your site there is nothing to map against. The Payload Library closes that gap: 574 hook payloads captured on our own test sites, across 19 sources — WordPress core, WooCommerce and the major form plugins — so Build with AI and the trigger panel can work from the real shape of an event your site has never fired. It matters most on the events that are awkward to produce on demand: a refunded order, a cancelled subscription, a deleted user. A reference payload is always labelled as one, and the moment the event really fires here your own capture takes over. ## Your own capture always wins Three sources are resolved in a fixed order. First this webhook's own capture. Then a capture of the same trigger on another webhook on this site — the payload shape of a `do_action` is trigger-global, so that is still your real data. Only when the site has neither is the library consulted. A library payload is never written into the site's stored example, so nothing has to be migrated or cleared later: the day the event fires for real, the reference payload simply stops being used. ## The shape is real, the values are ours Wherever a reference payload is in play it is named. The trigger panel shows a "WP Webhooks Payload Library" badge with the build the payload was captured on — `contact-form-7 6.1.7`, or WordPress itself for a core hook — and Build with AI carries the same badge on every step built from it and on the test delivery that proves it. `get_trigger_schema` says so in its response too, adding `example_source: "library"` alongside `captured_from`, `confidence`, and the paths it will not let you map. ## What a reference payload cannot know Containers your site defines — a form's fields, post or order meta, ACF — are flagged by name, and their contents are never mapped from a reference payload, whatever our fixture happens to hold in them. Mapping one would ship a `null` on a build that looks applied, so the plugin refuses rather than advises: a `set_mapping` or `set_conditions` step reaching under a site-defined container pauses the build, names the exact paths, and asks you to fire the event once so the mapping is built from your own fields. The trigger panel names the same containers next to the payload. ## Always a test delivery A build made from a reference payload has no capture step to prove it, so it always ends with a test delivery before the webhook can be enabled. That is the check that the mapping survives contact with your site's real data. ## When it is consulted The lookup runs on the hosted service, so it is available while Build with AI is running on WP Webhooks AI — the free 55-credit trial or a Pro credit pool. A site on its own provider key keeps the previous behaviour. A lookup itself costs no credits, and an empty credit pool does not withdraw it — the entitlement is the plan, not the balance. It fails open by design: unreachable, slow or unanswerable, the plugin lands back on exactly what it did before the library existed, and the site is asked to fire the event. Results are cached for a week, misses for a day. / Related [Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[WP Webhooks AI](https://wpwebhooks.org/docs/wp-webhooks-ai/)[API Docs Library](https://wpwebhooks.org/docs/api-docs-library/)[Payload Mapping](https://wpwebhooks.org/docs/payload-mapping/)[get\_trigger\_schema](https://wpwebhooks.org/docs/ability-get-trigger-schema/)[set\_mapping](https://wpwebhooks.org/docs/ability-set-mapping/)[Test Delivery](https://wpwebhooks.org/docs/test-delivery/)[fswa\_capture\_payload](https://wpwebhooks.org/docs/fswa-capture-payload/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Payload Library — Feature — Webhook Actions Docs","description":"A hosted reference payload for triggers your site has never fired, so Build with AI can map fields without asking you to fire the event first.","url":"https://wpwebhooks.org/docs/payload-library/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Payload Mapping" description: "Rename, restructure, or exclude fields in the outgoing JSON before dispatch using dot-notation paths." url: "https://wpwebhooks.org/docs/payload-mapping/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Payload Mapping Feature since v1.0.0 # Payload Mapping Rename, restructure, or exclude fields in the outgoing JSON before dispatch using dot-notation paths. Configure field mappings per webhook+trigger in the admin panel. Each mapping maps a source dot-notation path (e.g. `args.0.billing_email`) to a target path (e.g. `customer.email`). Fields not in a mapping can be passed through or excluded. Type casting (number, string, boolean, stringify) is supported on each mapped field. ## Dot-notation paths Paths use dot notation to address nested fields: `args.0.line_items.0.product_id`, `user.meta.company`. Arrays are addressed by index. The field selector in the admin shows a live payload tree to build paths without guessing. ## Type casting `number` — parses the value as float. `string` — coerces to string. `boolean` — casts truthy/falsy. `stringify` — JSON-encodes arrays or objects into a string before sending. / Demo / Related [fswa\_webhook\_payload](https://wpwebhooks.org/docs/fswa-webhook-payload/)[fswa\_capture\_payload](https://wpwebhooks.org/docs/fswa-capture-payload/)[Payload Library](https://wpwebhooks.org/docs/payload-library/)[Code Glue](https://wpwebhooks.org/docs/code-glue/)[Conditional Dispatch](https://wpwebhooks.org/docs/conditional-dispatch/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Payload Mapping — Feature — Webhook Actions Docs","description":"Rename, restructure, or exclude fields in the outgoing JSON before dispatch using dot-notation paths.","url":"https://wpwebhooks.org/docs/payload-mapping/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Per-Webhook Backoff Strategy" description: "Choose exponential, linear, or fixed backoff strategy and delay parameters per webhook." url: "https://wpwebhooks.org/docs/per-webhook-backoff/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Per-Webhook Backoff Strategy Feature since v1.8.0 # Per-Webhook Backoff Strategy Choose exponential, linear, or fixed backoff strategy and delay parameters per webhook. Configure the retry delay strategy for each webhook independently. Three strategies: `exponential` (base\_delay × 2^attempt, capped at max\_delay), `linear` (base\_delay × attempt), `fixed` (always base\_delay). Resolution order: per-webhook → site-wide setting → plugin default (exponential, 30s base, 3600s cap). / Related [Per-Webhook Retry Settings](https://wpwebhooks.org/docs/per-webhook-retry/)[Smart Retry with Exponential Backoff](https://wpwebhooks.org/docs/smart-retry/)[fswa\_backoff\_delay](https://wpwebhooks.org/docs/fswa-backoff-delay/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Per-Webhook Backoff Strategy — Feature — Webhook Actions Docs","description":"Choose exponential, linear, or fixed backoff strategy and delay parameters per webhook.","url":"https://wpwebhooks.org/docs/per-webhook-backoff/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Per-Webhook Retry Settings" description: "Override the global maximum retry attempt count for individual webhooks." url: "https://wpwebhooks.org/docs/per-webhook-retry/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Per-Webhook Retry Settings Feature since v1.8.0 # Per-Webhook Retry Settings Override the global maximum retry attempt count for individual webhooks. Set a custom retry limit per webhook from the webhook edit screen. The resolution order is: per-webhook setting → site-wide setting → plugin default (5). Critical webhooks can be configured for more attempts; low-priority notifications can be set to fewer. / Related [Per-Webhook Backoff Strategy](https://wpwebhooks.org/docs/per-webhook-backoff/)[Smart Retry with Exponential Backoff](https://wpwebhooks.org/docs/smart-retry/)[fswa\_max\_attempts](https://wpwebhooks.org/docs/fswa-max-attempts/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Per-Webhook Retry Settings — Feature — Webhook Actions Docs","description":"Override the global maximum retry attempt count for individual webhooks.","url":"https://wpwebhooks.org/docs/per-webhook-retry/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Post-Dispatch Scripting" description: "Run PHP code after a successful delivery to process the response and write data back to WordPress." url: "https://wpwebhooks.org/docs/post-dispatch-scripting/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Post-Dispatch Scripting Feature since v1.7.0 # Post-Dispatch Scripting Run PHP code after a successful delivery to process the response and write data back to WordPress. Attach a post-dispatch snippet to a webhook+trigger. It fires via `fswa_glue_post_dispatch` after a 2xx response. Use it to parse the response, persist returned IDs to post meta, or trigger follow-up logic. ## Available variables `$payload` — the payload that was sent. `$originalPayload` — the pre-mapping payload. `$responseCode` — the HTTP status code. `$responseBody` — the raw response body. ## Shorthand syntax Curly-brace placeholders resolve to array access for any variable: `{{ $originalPayload.args.0.id }}` → `$originalPayload['args'][0]['id']`. ## Return value The return value is ignored — use the snippet for side effects: `update_post_meta()`, `wp_mail()`, `error_log()`, etc. To act on the response with another request, chain a webhook after this one with Webhook Chains instead of calling an API here. / Examples Store a HubSpot deal ID returned in the response ``` // Available: $payload (sent), $originalPayload (pre-mapping), $responseCode, $responseBody $data = json_decode( $responseBody, true ); if ( isset( $data['id'], $payload['order_id'] ) ) { update_post_meta( $payload['order_id'], '_hs_deal_id', $data['id'] ); } ``` / Related [Code Glue](https://wpwebhooks.org/docs/code-glue/)[Webhook Chains](https://wpwebhooks.org/docs/webhook-chains/)[fswa\_glue\_post\_dispatch](https://wpwebhooks.org/docs/fswa-glue-post-dispatch/)[fswa\_webhook\_response](https://wpwebhooks.org/docs/fswa-webhook-response/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Post-Dispatch Scripting — Feature — Webhook Actions Docs","description":"Run PHP code after a successful delivery to process the response and write data back to WordPress.","url":"https://wpwebhooks.org/docs/post-dispatch-scripting/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "preview_snippet — AI Ability" description: "Preview (test-run) a Code Glue snippet — Test-run a snippet (by snippet_id, or raw code) against a real captured payload WITHOUT dispatching anything." url: "https://wpwebhooks.org/docs/ability-preview-snippet/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / preview\_snippet AI Ability since v1.3.0 # preview\_snippet Preview (test-run) a Code Glue snippet — Test-run a snippet (by snippet\_id, or raw code) against a real captured payload WITHOUT dispatching anything. Test-run a snippet (by snippet\_id, or raw code) against a real captured payload WITHOUT dispatching anything. Pass webhook\_id+trigger to use the captured example payload, or provide an explicit payload. Returns the transformed payload, any PHP error and printed output. Always preview before assigning. Registered as the WordPress Ability `flowsystems-webhook-actions/preview-snippet`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "preview_snippet", "input": { "snippet_id": …, "code": …, "webhook_id": …, "trigger": …, "payload": …, "mode": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. Runs without a confirmation pause (the overall plan is still user-approved before execution starts). / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | snippet\_id | param | integer | — | Saved snippet to run; omit when passing code. | | code | param | string | — | Raw PHP to run instead of a saved snippet. | | webhook\_id | param | integer | — | With trigger: use this webhook's captured example payload. | | trigger | param | string | — | | | payload | param | object | — | Explicit test payload; overrides the captured example. | | mode | param | string | — | pre | post | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "preview_snippet", "summary": "…", "input": { "snippet_id": 4, "webhook_id": 12, "trigger": "woocommerce_order_status_completed" } } ``` / Related [create\_snippet](https://wpwebhooks.org/docs/ability-create-snippet/)[assign\_snippet](https://wpwebhooks.org/docs/ability-assign-snippet/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"preview_snippet — AI Ability — Webhook Actions Docs","description":"Preview (test-run) a Code Glue snippet — Test-run a snippet (by snippet_id, or raw code) against a real captured payload WITHOUT dispatching anything.","url":"https://wpwebhooks.org/docs/ability-preview-snippet/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "probe_endpoint — AI Ability" description: "Probe a target endpoint — Make a guarded test HTTP call to validate an endpoint before going live." url: "https://wpwebhooks.org/docs/ability-probe-endpoint/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / probe\_endpoint AI Ability since v2.0.0 # probe\_endpoint Probe a target endpoint — Make a guarded test HTTP call to validate an endpoint before going live. Make a guarded test HTTP call to validate an endpoint before going live. To probe a webhook you created, pass webhook\_id (e.g. {{step\_2.id}}) — its URL, credential and method are reused automatically, so never ask the user for the endpoint URL again. Only pass url for an endpoint not tied to a webhook. Defaults to GET/HEAD; other methods require confirmation. Returns status, redacted headers and a truncated, redacted body. The raw secret is never exposed. Registered as the WordPress Ability `flowsystems-webhook-actions/probe-endpoint`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "probe_endpoint", "input": { "webhook_id": …, "url": …, "method": …, "auth_credential_id": …, "headers": …, "body": …, "confirmed": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. A probe sends an empty body, so GET, HEAD and POST run without confirmation; only PUT, PATCH and DELETE pause for it (external MCP/REST callers re-send with `"confirmed": true`). / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | webhook\_id | param | integer | — | Probe an existing webhook by id; reuses its endpoint URL, credential and method. Prefer this over url when probing a webhook you just created. | | url | param | string | — | | | method | param | string | — | GET | HEAD | POST | PUT | PATCH | DELETE | | auth\_credential\_id | param | integer | — | | | headers | param | object | — | | | body | param | object | — | | | confirmed | param | boolean | — | | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "probe_endpoint", "summary": "…", "input": { "webhook_id": "{{step_2.id}}" } } ``` / Related [create\_webhook](https://wpwebhooks.org/docs/ability-create-webhook/)[test\_dispatch](https://wpwebhooks.org/docs/ability-test-dispatch/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"probe_endpoint — AI Ability — Webhook Actions Docs","description":"Probe a target endpoint — Make a guarded test HTTP call to validate an endpoint before going live.","url":"https://wpwebhooks.org/docs/ability-probe-endpoint/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Publish a build" description: "Put a build on wpwebhooks.org as a public page others can import — and earn AI credits for it." url: "https://wpwebhooks.org/docs/publish-a-build/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Publish a build Feature since v2.5.0 # Publish a build Put a build on wpwebhooks.org as a public page others can import — and earn AI credits for it. Publish sends the same document "Share this build" would download to wpwebhooks.org, where it becomes a public page in the Integrations or Automations library. Anyone can read how it works and download the JSON to import into their own site. You keep 50 AI credits for publishing, plus 20 every time a reader marks it useful. ## What you fill in A title (it becomes the page headline), a short summary for the listing, and one collection — Integration or Automation. You choose that; it is never inferred, and a build is listed in a single collection. An optional author block credits you with your name, website and LinkedIn, published as `nofollow` links. ## Checked before it goes live Every submission runs through an automatic content check before publishing. It judges only the copy you wrote — endpoint URLs, PHP snippets and vendor names are normal here and are never grounds for rejection. If it declines, the reason comes straight back into the dialog so you can rewrite and retry. If our provider is unavailable the build is accepted and queued for review rather than rejected, and the credits still land. ## What never travels Secrets are never included — vault credentials export as a masked reference, and whoever imports the build links their own. Captured example payloads are anonymized automatically, and unlike a private download that is not an option you can switch off: a published build is a public page. ## Credits and likes Publishing grants 50 AI credits to your license, and each reader who marks the build useful adds 20 more. Rewards land in the same top-up pool as purchased credits, so they survive the monthly reset. One like counts per reader, per build. / Setup 1. 1 **Finish a build, then choose "Publish your build"** In Webhook Actions → Build with AI, run a build to completion. "Publish your build" sits in the completed-build row next to "Share this build", and needs an active Pro license. 2. 2 **Fill in the listing** Give the build a title — it becomes the page headline — a short summary for the listing, and one collection: Integration or Automation. Summaries are Markdown, with a Write/Preview toggle. 3. 3 **Publish it** The submission runs through the automatic content check, then goes live. The address of the new page comes straight back into the dialog, and 50 AI credits land on your license. 4. 4 **Your page on wpwebhooks.org** Give it about a minute and a half before following the link — published pages are baked out when the site next rebuilds, so the URL is not live the instant you publish. The page then carries your title and summary, a badge marking it as built with AI, and a Download build JSON button anyone can use to import it into their own site. Each reader who marks it useful adds 20 more credits. 5. 5 **The conversation that built it** If you left "Include the AI conversation" ticked, the page carries it next — your prompts and the agent's replies, with the model it ran on. Read results never travel, obvious secret shapes are masked, and personal data quoted back by the agent is anonymized. 6. 6 **Readers can see how it works** Further down, the page lists the abilities the agent called to make the build and diagrams what runs — which WordPress action fires, any Code Glue in the path, and where the payload is sent. / Related [Share a build](https://wpwebhooks.org/docs/share-a-build/)[Captured payload anonymization](https://wpwebhooks.org/docs/payload-anonymization/)[WP Webhooks AI](https://wpwebhooks.org/docs/wp-webhooks-ai/)[Import & Export](https://wpwebhooks.org/docs/import-export/)[Browse published builds](https://wpwebhooks.org/automations/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Publish a build — Feature — Webhook Actions Docs","description":"Put a build on wpwebhooks.org as a public page others can import — and earn AI credits for it.","url":"https://wpwebhooks.org/docs/publish-a-build/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "REST API" description: "Full operational REST API at `/wp-json/fswa/v1/` for managing webhooks, logs, queue, credentials, and tokens." url: "https://wpwebhooks.org/docs/rest-api/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / REST API Feature since v1.3.0 # REST API Full operational REST API at \`/wp-json/fswa/v1/\` for managing webhooks, logs, queue, credentials, and tokens. The plugin exposes a complete REST API covering webhooks (CRUD + toggle + test), delivery logs (list, get, delete, bulk-retry, replay), queue (list, stats, execute, retry, delete), health metrics, trigger discovery, payload schemas, the Credentials Vault, API tokens, and webhook chains. This is the same API that powers the admin UI. ## Use cases CI/CD pipelines can disable webhooks during deploys and re-enable them after. AI agents can inspect logs and retry failures. External dashboards can pull delivery stats. All without WordPress admin sessions — use scoped API tokens, including the `agent` scope that grants full write access without ever exposing stored secrets. / Related [API Tokens](https://wpwebhooks.org/docs/api-tokens/)[Credentials Vault](https://wpwebhooks.org/docs/credentials-vault/)[Full REST API Reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"REST API — Feature — Webhook Actions Docs","description":"Full operational REST API at `/wp-json/fswa/v1/` for managing webhooks, logs, queue, credentials, and tokens.","url":"https://wpwebhooks.org/docs/rest-api/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "set_conditions — AI Ability" description: "Set conditions — Set conditional-dispatch rules for a webhook+trigger so only matching events leave the site." url: "https://wpwebhooks.org/docs/ability-set-conditions/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / set\_conditions AI Ability since v2.0.0 # set\_conditions Set conditions — Set conditional-dispatch rules for a webhook+trigger so only matching events leave the site. Set conditional-dispatch rules for a webhook+trigger so only matching events leave the site. conditions MUST be an object {"enabled":true,"type":"and"|"or","rules":\[{"field":"","operator":"equals|not\_equals|contains|not\_contains|greater\_than|less\_than|is\_empty|is\_not\_empty|is\_true|is\_false|array\_contains|object\_contains","value":"..."}\]}. A rule may add an optional "cast":"number"|"string"|"boolean"|"stringify" to coerce the payload value before comparing (e.g. numeric compare on a string field). A rules item may also be a nested group {"type":"group","match":"and"|"or","rules":\[...\]}, and there is no limit on how many rules a webhook may carry. Run get\_trigger\_schema first and take field paths from the real captured payload (e.g. "args.0.form\_id"). When the example backing this step came from the Payload Library rather than a capture on this site, any path reaching under a site-defined container — a form's fields, post or order meta, ACF — is refused: the step pauses, names the offending paths, and asks for the event to be fired once so the real keys are known. Registered as the WordPress Ability `flowsystems-webhook-actions/set-conditions`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "set_conditions", "input": { "webhook_id": …, "trigger": …, "conditions": …, "conditions_evaluate_on": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. Runs without a confirmation pause (the overall plan is still user-approved before execution starts). / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | webhook\_id | param | integer | yes | | | trigger | param | string | yes | | | conditions | param | object | yes | | | conditions\_evaluate\_on | param | string | — | original | transformed | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "set_conditions", "summary": "…", "input": { "webhook_id": 12, "trigger": "wpcf7_mail_sent", "conditions": { "enabled": true, "type": "and", "rules": [ { "field": "args.0.form_id", "operator": "equals", "value": "16", "cast": "number" } ] } } } ``` / Related [get\_trigger\_schema](https://wpwebhooks.org/docs/ability-get-trigger-schema/)[Conditional Dispatch](https://wpwebhooks.org/docs/conditional-dispatch/)[Payload Library](https://wpwebhooks.org/docs/payload-library/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"set_conditions — AI Ability — Webhook Actions Docs","description":"Set conditions — Set conditional-dispatch rules for a webhook+trigger so only matching events leave the site.","url":"https://wpwebhooks.org/docs/ability-set-conditions/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "set_mapping — AI Ability" description: "Set field mapping — Set the payload field mapping for a webhook+trigger (rename / restructure / exclude / type-cast fields with dot-notation paths)." url: "https://wpwebhooks.org/docs/ability-set-mapping/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / set\_mapping AI Ability since v2.0.0 # set\_mapping Set field mapping — Set the payload field mapping for a webhook+trigger (rename / restructure / exclude / type-cast fields with dot-notation paths). Set the payload field mapping for a webhook+trigger (rename / restructure / exclude / type-cast fields with dot-notation paths). When the example backing this step came from the Payload Library rather than a capture on this site, any path reaching under a site-defined container — a form's fields, post or order meta, ACF — is refused: the step pauses, names the offending paths, and asks for the event to be fired once so the real keys are known. Registered as the WordPress Ability `flowsystems-webhook-actions/set-mapping`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "set_mapping", "input": { "webhook_id": …, "trigger": …, "field_mapping": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. Runs without a confirmation pause (the overall plan is still user-approved before execution starts). / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | webhook\_id | param | integer | yes | | | trigger | param | string | yes | | | field\_mapping | param | object | yes | Mapping definition as used by the mapping UI. | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "set_mapping", "summary": "…", "input": { "webhook_id": 12, "trigger": "wpcf7_mail_sent", "field_mapping": { "enabled": true, "rules": [ { "source": "args.0.posted_data.your-email", "target": "email" } ] } } } ``` / Related [get\_trigger\_schema](https://wpwebhooks.org/docs/ability-get-trigger-schema/)[Payload Mapping](https://wpwebhooks.org/docs/payload-mapping/)[Payload Library](https://wpwebhooks.org/docs/payload-library/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"set_mapping — AI Ability — Webhook Actions Docs","description":"Set field mapping — Set the payload field mapping for a webhook+trigger (rename / restructure / exclude / type-cast fields with dot-notation paths).","url":"https://wpwebhooks.org/docs/ability-set-mapping/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Share a build" description: "Export exactly what one Build with AI conversation created — optionally with the conversation itself." url: "https://wpwebhooks.org/docs/share-a-build/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Share a build Feature since v2.5.0 # Share a build Export exactly what one Build with AI conversation created — optionally with the conversation itself. When Build with AI finishes, "Share this build" downloads everything that run created — every webhook, chain, mapping, condition and Code Glue snippet — as the same portable JSON an Import & Export download produces. You do not pick the objects by hand: the plugin resolves them from the run's applied steps, so the file matches the build rather than whatever you remembered to tick. ## Describe it before it leaves A build that travels is documentation as much as configuration, so the dialog lists everything the file will contain and opens an editor on anything still undescribed. Descriptions you write there are saved to the webhook or chain itself, not just to the copy — the site and the shared file stay in agreement. ## Include the AI conversation (Pro) Optionally attach the conversation that produced the build as display-only provenance: your prompts and the assistant's replies, plus the plan steps that were applied. Read results never travel, obvious secret shapes are masked, and personal data quoted back by the agent is anonymized. Off by default — a conversation can still mention details about your site, so review it before sharing publicly. ## Anonymize your site address One toggle replaces your site URL with `example.com` everywhere it appears — the export header, endpoint URLs, Code Glue and the conversation. Whoever imports the build re-points those endpoints at their own site. / Related [Publish a build](https://wpwebhooks.org/docs/publish-a-build/)[Import & Export](https://wpwebhooks.org/docs/import-export/)[Captured payload anonymization](https://wpwebhooks.org/docs/payload-anonymization/)[Build with AI](https://wpwebhooks.org/docs/build-with-ai/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Share a build — Feature — Webhook Actions Docs","description":"Export exactly what one Build with AI conversation created — optionally with the conversation itself.","url":"https://wpwebhooks.org/docs/share-a-build/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Smart Retry with Exponential Backoff" description: "Failed deliveries retry automatically with exponential backoff; 4xx failures are classified as permanent." url: "https://wpwebhooks.org/docs/smart-retry/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Smart Retry with Exponential Backoff Feature since v1.1.0 # Smart Retry with Exponential Backoff Failed deliveries retry automatically with exponential backoff; 4xx failures are classified as permanent. 5xx and 429 responses trigger retries with exponential backoff. The wait before attempt N is `base_delay × 2^N` capped at `max_delay`, so on the defaults (5 attempts, 30s base, 1 hour cap) a failing delivery is retried after 60s, 120s, 240s and 480s and gives up about 15 minutes in. 4xx and 3xx responses are classified as `permanently_failed` immediately — bad payloads are not worth retrying. Each attempt is logged with its HTTP status and response body. ## Configurable via filters Override max attempts per webhook via `fswa_max_attempts` and delay per attempt via `fswa_backoff_delay`. The webhook edit screen also carries per-webhook retry settings and a choice of exponential, linear, or fixed backoff strategy. / Related [fswa\_max\_attempts](https://wpwebhooks.org/docs/fswa-max-attempts/)[fswa\_backoff\_delay](https://wpwebhooks.org/docs/fswa-backoff-delay/)[Per-Webhook Retry Settings](https://wpwebhooks.org/docs/per-webhook-retry/)[Per-Webhook Backoff Strategy](https://wpwebhooks.org/docs/per-webhook-backoff/)[Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Smart Retry with Exponential Backoff — Feature — Webhook Actions Docs","description":"Failed deliveries retry automatically with exponential backoff; 4xx failures are classified as permanent.","url":"https://wpwebhooks.org/docs/smart-retry/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Synchronous Execution" description: "Optionally fire a webhook inline during the WordPress request, bypassing the queue for the first attempt." url: "https://wpwebhooks.org/docs/synchronous-execution/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Synchronous Execution Feature since v1.10.0 # Synchronous Execution Optionally fire a webhook inline during the WordPress request, bypassing the queue for the first attempt. Enable per-webhook to deliver the first attempt synchronously, blocking the current WordPress request. Useful when the response must be available immediately (e.g. to store a returned ID). If the synchronous attempt fails with a retryable error (5xx, transport), the job falls back to the async queue automatically. Non-retryable failures (4xx) are marked permanently failed inline. / Related [Async Delivery Queue](https://wpwebhooks.org/docs/async-queue/)[Smart Retry with Exponential Backoff](https://wpwebhooks.org/docs/smart-retry/)[fswa\_webhook\_response](https://wpwebhooks.org/docs/fswa-webhook-response/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Synchronous Execution — Feature — Webhook Actions Docs","description":"Optionally fire a webhook inline during the WordPress request, bypassing the queue for the first attempt.","url":"https://wpwebhooks.org/docs/synchronous-execution/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Test Delivery" description: "Fire a test webhook instantly or via the queue without triggering real WordPress events." url: "https://wpwebhooks.org/docs/test-delivery/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Test Delivery Feature since v1.7.0 # Test Delivery Fire a test webhook instantly or via the queue without triggering real WordPress events. Send a test delivery from the admin panel using either the stored captured payload or a custom JSON body. Two modes: Run Now (synchronous, response shown immediately) or Queue (processes through the full async pipeline including retry and log). Test entries appear in delivery logs with a `test` status. / Related [Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/)[Webhook Replay](https://wpwebhooks.org/docs/webhook-replay/)[Synchronous Execution](https://wpwebhooks.org/docs/synchronous-execution/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Test Delivery — Feature — Webhook Actions Docs","description":"Fire a test webhook instantly or via the queue without triggering real WordPress events.","url":"https://wpwebhooks.org/docs/test-delivery/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "test_dispatch — AI Ability" description: "Test-dispatch a webhook — Send a synchronous test delivery for a webhook using a provided or captured payload, and return the HTTP result so the agent can verify the integration end to end." url: "https://wpwebhooks.org/docs/ability-test-dispatch/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / test\_dispatch AI Ability since v2.0.0 # test\_dispatch Test-dispatch a webhook — Send a synchronous test delivery for a webhook using a provided or captured payload, and return the HTTP result so the agent can verify the integration end to end. Send a synchronous test delivery for a webhook using a provided or captured payload, and return the HTTP result so the agent can verify the integration end to end. Registered as the WordPress Ability `flowsystems-webhook-actions/test-dispatch`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "test_dispatch", "input": { "webhook_id": …, "trigger": …, "payload": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. Runs without a confirmation pause (the overall plan is still user-approved before execution starts). / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | webhook\_id | param | integer | yes | | | trigger | param | string | — | | | payload | param | object | — | Optional custom payload; falls back to the captured example. | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "test_dispatch", "summary": "…", "input": { "webhook_id": 12, "trigger": "wpcf7_mail_sent" } } ``` / Related [probe\_endpoint](https://wpwebhooks.org/docs/ability-probe-endpoint/)[get\_logs](https://wpwebhooks.org/docs/ability-get-logs/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"test_dispatch — AI Ability — Webhook Actions Docs","description":"Test-dispatch a webhook — Send a synchronous test delivery for a webhook using a provided or captured payload, and return the HTTP result so the agent can verify the integration end to end.","url":"https://wpwebhooks.org/docs/ability-test-dispatch/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Unlimited Conditions" description: "Define unlimited condition rules and groups with AND/OR logic between groups." url: "https://wpwebhooks.org/docs/unlimited-conditions/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Unlimited Conditions Feature since v1.7.0 # Unlimited Conditions Define unlimited condition rules and groups with AND/OR logic between groups. Free from 3.0.0 — there is no cap on rules per webhook. Rules are organized into condition groups, evaluated with AND between rules within a group and AND/OR between groups, enabling dispatch logic like "fire only for orders over $100 in category X OR for VIP customers". Build with AI can create these groups for you from a plain-language description, on the free plugin — there is no licence check anywhere in the abilities layer. / Related [Conditional Dispatch](https://wpwebhooks.org/docs/conditional-dispatch/)[fswa\_should\_dispatch](https://wpwebhooks.org/docs/fswa-should-dispatch/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Unlimited Conditions — Feature — Webhook Actions Docs","description":"Define unlimited condition rules and groups with AND/OR logic between groups.","url":"https://wpwebhooks.org/docs/unlimited-conditions/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "update_snippet — AI Ability" description: "Update a Code Glue snippet — Update an existing Code Glue snippet's name, code or tags." url: "https://wpwebhooks.org/docs/ability-update-snippet/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / update\_snippet AI Ability since v1.3.0 # update\_snippet Update a Code Glue snippet — Update an existing Code Glue snippet's name, code or tags. Update an existing Code Glue snippet's name, code or tags. If the snippet is already assigned and enabled on a webhook, the new code runs on the next dispatch — re-test with preview\_snippet after editing. Registered as the WordPress Ability `flowsystems-webhook-actions/update-snippet`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "update_snippet", "input": { "id": …, "name": …, "code": …, "tags": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. Runs without a confirmation pause (the overall plan is still user-approved before execution starts). / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | id | param | integer | yes | | | name | param | string | — | | | code | param | string | — | | | tags | param | array | — | | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "update_snippet", "summary": "…", "input": { "id": 4, "code": "$payload[\"tag\"] = {{ $args.0.total }} > 250 ? \"vip\" : \"standard\";\nreturn $payload;" } } ``` / Related [preview\_snippet](https://wpwebhooks.org/docs/ability-preview-snippet/)[get\_snippet](https://wpwebhooks.org/docs/ability-get-snippet/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"update_snippet — AI Ability — Webhook Actions Docs","description":"Update a Code Glue snippet — Update an existing Code Glue snippet's name, code or tags.","url":"https://wpwebhooks.org/docs/ability-update-snippet/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "update_webhook — AI Ability" description: "Update a webhook — Update an existing webhook (endpoint, method, triggers, headers, credential)." url: "https://wpwebhooks.org/docs/ability-update-webhook/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / update\_webhook AI Ability since v2.0.0 # update\_webhook Update a webhook — Update an existing webhook (endpoint, method, triggers, headers, credential). Update an existing webhook (endpoint, method, triggers, headers, credential). Registered as the WordPress Ability `flowsystems-webhook-actions/update-webhook`, so external AI tools (Claude Code, Cursor) can call it over MCP, or over the Abilities REST route with a scoped API token. / Plan step shape ``` { "ability": "update_webhook", "input": { "id": …, "name": …, "endpoint_url": …, "http_method": …, "triggers": …, "auth_credential_id": …, "custom_headers": …, "url_params": … } } ``` / Scope & confirmation Minimum API-token scope for external (MCP/REST) callers: `full`. Runs without a confirmation pause (the overall plan is still user-approved before execution starts). / Input fields | Name | In | Type | Req | Description | | --- | --- | --- | --- | --- | | id | param | integer | yes | | | name | param | string | — | | | endpoint\_url | param | string | — | | | http\_method | param | string | — | GET | POST | PUT | PATCH | DELETE | | triggers | param | array | — | | | auth\_credential\_id | param | integer | — | | | custom\_headers | param | object | — | | | url\_params | param | object | — | | | is\_synchronous | param | boolean | — | Delivery mode. false = asynchronous (queued, delivered in the background on the next cron tick, with automatic retries). true = synchronous (delivered inline during the triggering request, so it works without a reliable cron, at the cost of a little latency on that request). Omit to follow the site default. | | retry\_limit | param | integer | — | Delivery attempts before the job permanently fails, 1-100. Omit to inherit the site-wide setting, then the default of 5. | | backoff\_strategy | param | string | — | How the wait between retries grows. Omit to inherit the site-wide setting, then exponential. exponential | linear | fixed | | backoff\_base\_delay | param | integer | — | Seconds the strategy multiplies, 1-86400. Omit to inherit, then 30. | | backoff\_max\_delay | param | integer | — | Ceiling in seconds for exponential backoff, 1-86400. Omit to inherit, then 3600. | / Examples Plan step as Build with AI proposes it ``` { "id": "step_1", "ability": "update_webhook", "summary": "…", "input": { "id": 12, "endpoint_url": "https://n8n.example.com/webhook/new-path" } } ``` / Related [get\_webhook](https://wpwebhooks.org/docs/ability-get-webhook/)[create\_webhook](https://wpwebhooks.org/docs/ability-create-webhook/)[REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"update_webhook — AI Ability — Webhook Actions Docs","description":"Update a webhook — Update an existing webhook (endpoint, method, triggers, headers, credential).","url":"https://wpwebhooks.org/docs/ability-update-webhook/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "URL Query Parameters" description: "Append static or payload-resolved query parameters to the endpoint URL at dispatch time." url: "https://wpwebhooks.org/docs/url-query-params/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / URL Query Parameters Feature since v1.9.0 # URL Query Parameters Append static or payload-resolved query parameters to the endpoint URL at dispatch time. Configure key/value query parameters per webhook. Parameter values support dot-notation paths resolved against the outgoing payload. For GET and DELETE requests, query parameters are the primary payload transport. The fully resolved URL (including all params) is stored in delivery logs. / Related [Custom Request Headers](https://wpwebhooks.org/docs/custom-headers/)[Configurable HTTP Methods](https://wpwebhooks.org/docs/http-methods/)[Dynamic URL Templates](https://wpwebhooks.org/docs/dynamic-url-templates/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"URL Query Parameters — Feature — Webhook Actions Docs","description":"Append static or payload-resolved query parameters to the endpoint URL at dispatch time.","url":"https://wpwebhooks.org/docs/url-query-params/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Webhook Chains" description: "A webhook completing with a 2xx response can automatically trigger one or more downstream webhooks." url: "https://wpwebhooks.org/docs/webhook-chains/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Webhook Chains Feature since v1.13.0 # Webhook Chains A webhook completing with a 2xx response can automatically trigger one or more downstream webhooks. Link webhooks into multi-step automation sequences. When a webhook delivers successfully, any chained downstream webhooks fire with the upstream response body, sent payload, and original pre-mapping payload available as their starting arguments. Chains have names, support per-link conditions, and are cycle-detected at save time. ## Example WooCommerce order → HubSpot POST (create deal) → HubSpot PATCH (update deal with line items). Each hop is a first-class webhook with its own log, retry, conditions, and replay. / Related [fswa\_glue\_post\_dispatch](https://wpwebhooks.org/docs/fswa-glue-post-dispatch/)[Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/)[Post-Dispatch Scripting](https://wpwebhooks.org/docs/post-dispatch-scripting/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Webhook Chains — Feature — Webhook Actions Docs","description":"A webhook completing with a 2xx response can automatically trigger one or more downstream webhooks.","url":"https://wpwebhooks.org/docs/webhook-chains/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Webhook Replay" description: "Re-send any logged event — successful, failed, or condition-skipped — using its original payload." url: "https://wpwebhooks.org/docs/webhook-replay/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / Webhook Replay Feature since v1.2.0 # Webhook Replay Re-send any logged event — successful, failed, or condition-skipped — using its original payload. Any delivery log entry can be replayed from the admin panel. The replay uses the exact original payload and event metadata. Condition-skipped events can be replayed after modifying the webhook's conditions — useful for back-filling missed events. Replays appear in logs with their own attempt history. / Related [Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/)[Conditional Dispatch](https://wpwebhooks.org/docs/conditional-dispatch/)[Test Delivery](https://wpwebhooks.org/docs/test-delivery/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"Webhook Replay — Feature — Webhook Actions Docs","description":"Re-send any logged event — successful, failed, or condition-skipped — using its original payload.","url":"https://wpwebhooks.org/docs/webhook-replay/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "WP Webhooks AI" description: "The hosted model service behind Build with AI — a free 55-credit trial on every install, then a monthly pool of credits with every Pro license. No provider signup, no API keys to paste." url: "https://wpwebhooks.org/docs/wp-webhooks-ai/" --- [WP Webhooks](https://wpwebhooks.org/) / [Docs](https://wpwebhooks.org/docs/) / WP Webhooks AI Feature Pro since v2.3.0 # WP Webhooks AI The hosted model service behind Build with AI — a free 55-credit trial on every install, then a monthly pool of credits with every Pro license. No provider signup, no API keys to paste. WP Webhooks AI is the hosted model service behind Build with AI: our servers, our provider keys, nothing for you to sign up for. The free plugin gets a one-time 55-credit trial on it, and every Pro license adds a monthly pool of AI credits that renews automatically. It appears as "WP Webhooks AI (included)" under the Build with AI model source whenever the Pro plugin is active, and the bring-your-own-key option never goes away. ## Free trial on every install Since 2.8.0 the free plugin runs on the same hosted service without a license. Your first prompt to Build with AI claims a one-time grant of 55 credits — about five agent turns, or two complete automations — with no key, no account and no card. The balance shows in the model bar and on the Free trial card in Settings → AI Builder. It is one trial per site and it is never refilled: when it runs out, connect your own provider key or activate Pro to keep building. The trial is also the last source Auto resolves to, so a site that already has connectors or its own key never spends it. ## Included with every Pro plan Each tier renews a monthly credit pool — Starter 5,000, Business 12,500, Agency 30,000. Credits are charged by usage: a typical agent turn costs about 11 credits, and a full automation — planning it, reading your hooks and REST routes, creating the webhook, mapping fields, and test-firing it — usually lands around 25. That puts even the smallest plan at 200+ builds a month, Business at 500+ and Agency at 1,200+. Credits reset at the start of each billing period. ## More than a model Running on the hosted service also opens the Payload Library — reference payloads for triggers your site has never fired, so the agent can map fields without sending you off to produce the event by hand first. The lookup itself costs no credits. It also opens the API Docs Library — the current reference for HubSpot, Airtable, Slack, Notion, Stripe and dozens more, read into any turn that targets one, and researched from the vendor's docs on the spot when nobody has asked for that service before; about one credit on the turn that uses it. A site building on its own provider key is paying its own bills and keeps the previous behaviour. ## Automatic on activation Activating a Pro license switches Build with AI to the hosted service automatically — but it never overrides an explicit choice: if you have deliberately selected your own API key or the WordPress AI connectors, that selection stays. The hosted status travels on every settings response, so switching between Auto, connectors, your own keys, and WP Webhooks AI never loses the option. ## Live balance as you build The model bar in Build with AI shows how many credits are left and counts down after every agent call. The current balance and the monthly reset date are also shown in provider settings, so there is never a surprise about what remains. ## When credits run out Out-of-credits, rate-limit, and provider errors surface directly in the builder chat rather than failing silently — and you can switch back to your own provider key at any time to keep working. A spent free trial is treated as a choice rather than an error: the builder says so, points you at a free Gemini key or Pro credits, and everything you have already built stays on the site untouched. The routes forward survive a page reload, and they disappear the moment another model is connected. / Related [Build with AI](https://wpwebhooks.org/docs/build-with-ai/)[Payload Library](https://wpwebhooks.org/docs/payload-library/)[API Docs Library](https://wpwebhooks.org/docs/api-docs-library/)[Publish a build](https://wpwebhooks.org/docs/publish-a-build/)[Credentials Vault](https://wpwebhooks.org/docs/credentials-vault/)[AI Dev Trace](https://wpwebhooks.org/docs/ai-dev-trace/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","name":"WP Webhooks AI — Feature — Webhook Actions Docs","description":"The hosted model service behind Build with AI — a free 55-credit trial on every install, then a monthly pool of credits with every Pro license. No provider signup, no API keys to paste.","url":"https://wpwebhooks.org/docs/wp-webhooks-ai/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"}} ``` --- --- title: "Record newly published posts into Airtable — Integration" description: "New post lands as a row in an Airtable base. No plugin dependency." url: "https://wpwebhooks.org/integrations/record-newly-published-posts-into-airtable/" date: "2026-08-02" --- [WP Webhooks](https://wpwebhooks.org/) / [Integrations](https://wpwebhooks.org/integrations/) / Record newly published posts into Airtable Webhook [Built with AI — read the conversation ↓](#the-conversation) #airtable#code-glue#posts # Record newly published posts into Airtable **Get these three things before start:** 1. **PAT** — airtable.com → account menu → _Builder hub_ → _Personal access tokens_ → create one with scope `data.records:write` and access to the base you want. Starts `pat…`. 2. **Base ID** — open the base, then _Help_ → _API documentation_. The URL contains it: `airtable.com/appXXXXXXXXXXXXXX/api/docs` → base ID is `appXXXXXXXXXXXXXX`. 3. **Table name** — exactly as shown on the tab, e.g. `Posts`. Case and spaces matter. Create the table columns before building: `Title` (single line), `URL` (URL), `Author` (single line), `Published` (date). Save the PAT in the vault name it as `Airtable`. **Gotcha:** a field name that doesn't exist in the table gives `422 UNKNOWN_FIELD_NAME` — Airtable will not create columns for you. If the date lands as text, `typecast` was dropped from the payload. / The conversation This build was not assembled by hand. Its author described what they wanted in [Build with AI](https://wpwebhooks.org/docs/build-with-ai/), and the agent read the site, planned the work and applied it. This is that conversation. model gemini-flash-latest via hosted AI credits Author hey, lets build integration with airtable, so newly published posts are recorded there. Airtable base is app3HGOeVWJCfQRsy Table name is Newly Published Posts. Columns we have there are Title, URL, Author and Published Build with AI Checking available triggers and saved credentials for Airtable... Build with AI Checking for WordPress core post triggers and trigger payload schema... Build with AI Checking trigger schema for post transition hooks... Build with AI I will set up a new webhook pointing to your Airtable table (`Newly Published Posts`) and attach your existing Airtable credential (ID #10). Because the `publish_post` trigger has not captured a sample payload on this site yet, field mapping cannot be applied just yet. Once this plan is executed, please publish or update a test post to capture the payload shape so we can complete the column mapping (Title, URL, Author, Published) and go live. Author Alright, I have published test post so you should have access to it. Build with AI Checking the newly captured payload schema for the `publish_post` trigger... Build with AI Now that we have captured the real post payload, here is the plan to finish setting up the integration: 1. **Code Glue pre-dispatch snippet**: Formats the outgoing payload into Airtable's expected JSON structure under the `fields` object for the requested columns: - `Title`: `args.1.post_title` - `URL`: post permalink - `Author`: display name of `args.1.post_author` - `Published`: `args.1.post_date` 2. **Preview & Assign snippet**: Applies the snippet pre-dispatch to webhook #29. 3. **Test Dispatch**: Sends a synchronous test delivery with the captured payload to verify Airtable accepts the record. 4. **Enable Webhook**: Turns on Webhook #29 so future published posts are posted automatically to Airtable. Author Got error: Build with AI Checking the delivery logs for webhook #29 to see the detailed error response... Build with AI Checking the existing snippets to get the exact ID to update... Build with AI Airtable rejected the `Published` date format (`2026-08-02 22:07:31`). Airtable Date/DateTime columns require an ISO 8601 formatted timestamp (e.g., `2026-08-02T20:07:31Z` using `post_date_gmt`). I will update snippet #5 to format the date in ISO 8601 UTC format, preview it, test dispatch to Airtable, and enable Webhook #29. Author there is still the same error Build with AI Checking the latest delivery logs for webhook #29... Build with AI Airtable column types can be strict about date formatting and strict type matching. Adding `'typecast' => true` to the request allows Airtable to automatically coerce the formatted date (`YYYY-MM-DD`) into your `Published` date column. I will update snippet #5 with `typecast: true` and `YYYY-MM-DD` date formatting, test dispatch to Airtable, and enable Webhook #29. Published by the author on purpose. Tool output and raw payloads never travel with a shared build, and secrets never leave the site they were created on. / Abilities used (13) 1. create\_webhook Create webhook targeting Airtable table 'Newly Published Posts' 2. assign\_credential Attach stored Airtable bearer credential (#10) 3. create\_snippet Create Code Glue snippet to format payload for Airtable API 4. preview\_snippet Preview snippet against captured payload 5. assign\_snippet Attach pre-dispatch snippet to webhook #29 6. test\_dispatch Test dispatch to Airtable API failed 7. update\_snippet Update snippet #5 to output ISO 8601 UTC date for Airtable 8. preview\_snippet Preview updated snippet #5 against captured payload 9. test\_dispatch Test dispatch to Airtable API failed 10. update\_snippet Update Code Glue snippet #5 to include typecast: true and standard YYYY-MM-DD date format 11. preview\_snippet Preview updated snippet against captured payload 12. test\_dispatch Test dispatch payload to Airtable API 13. enable\_webhook Enable Webhook #29 (Go Live) Each of these is a real ability the agent can call — see [the docs](https://wpwebhooks.org/docs/) for what each one does. / What runs Publish Post to Airtable WordPress fires publish\_post → Code Glue Format Post for Airtable before sending → Queued, then sent POST https://api.airtable.com/v0/app3HGOeVWJCfQRsy/Newly%20Published%20Posts with your credential Every attempt is retried on its own schedule and written to the delivery log. WordPress fires the action `publish_post`, the payload is sent as POST to `api.airtable.com`. The delivery is queued and sent in the background, so the visitor never waits. Along the way a Code Glue snippet shapes the payload first and authentication comes from a credential you supply on import. / How it is configured Publish Post to Airtable publish\_post / Code Glue before sending — Format Post for Airtable ``` $gmtDate = $args[1]['post_date_gmt'] ?? ''; $publishedDate = !empty($gmtDate) ? gmdate('Y-m-d', strtotime($gmtDate)) : ''; $authorId = (int) ($args[1]['post_author'] ?? 0); $author = $authorId ? get_the_author_meta('display_name', $authorId) : ''; $postId = (int) ($args[1]['ID'] ?? 0); return [ 'fields' => [ 'Title' => $args[1]['post_title'] ?? '', 'URL' => $postId ? get_permalink($postId) : ($args[1]['guid'] ?? ''), 'Author' => $author, 'Published' => $publishedDate ], 'typecast' => true ]; ``` Read any snippet before you import it — Code Glue runs PHP on your site. / Authentication This build authenticates with a credential you supply on import. The shared file carries the name and type only — never the secret. - Airtable — bearer / Use this build Download the file, then in WordPress open Webhook Actions → Webhooks → Import and drop it in. Anything needing authentication asks you to pick or create a credential as it imports — see [Import & Export](https://wpwebhooks.org/docs/import-export/) for what travels with a build and what does not. A like sends 20 AI credits to whoever published this. / Published by Flow Systems [Website ↗](https://wpwebhooks.org) / Questions Do I need a paid plan for this build? + No. Everything a build can contain — webhooks, triggers, field mapping, conditions, retries, chains and Code Glue snippets — is in the free plugin from 3.0.0 on. A paid plan buys hosted AI credits and external cron, neither of which an imported build needs. How do I use it on my own site? + Download the JSON, then in WordPress open Webhook Actions → Webhooks → Import and drop the file in. Anything that needs authentication asks you to pick or create a credential during the import — secrets are never part of a shared build. Does the build contain the author's API keys? + No. Export carries credential metadata only — the name, the type and a masked hint — never the secret itself. The Credentials Vault is write-only over the API by design. /Publish ## Publish your integration and get 50 AI credits, +20 for each like. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Get Pro →](https://wpwebhooks.org/pricing/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","headline":"Record newly published posts into Airtable","description":"New post lands as a row in an Airtable base. No plugin dependency.","url":"https://wpwebhooks.org/integrations/record-newly-published-posts-into-airtable/","datePublished":"2026-08-02T20:27:22+00:00","author":{"@type":"Person","name":"Flow Systems"},"image":{"@type":"ImageObject","url":"https://wpwebhooks.org/builds/record-newly-published-posts-into-airtable/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration titled “Record newly published posts into Airtable — technical guide | automation tutorial”: a glowing “POST” card on the left feeds a conveyor of article panels each labelled “New”, whose light streams run through an operator at a holographic console marked “Processing posts…” into an “Airtable base” panel on the right, showing a “Recorded posts” table with Title, Link, Date and Author columns."},"isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"},"mainEntity":{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Do I need a paid plan for this build?","acceptedAnswer":{"@type":"Answer","text":"No. Everything a build can contain — webhooks, triggers, field mapping, conditions, retries, chains and Code Glue snippets — is in the free plugin from 3.0.0 on. A paid plan buys hosted AI credits and external cron, neither of which an imported build needs."}},{"@type":"Question","name":"How do I use it on my own site?","acceptedAnswer":{"@type":"Answer","text":"Download the JSON, then in WordPress open Webhook Actions → Webhooks → Import and drop the file in. Anything that needs authentication asks you to pick or create a credential during the import — secrets are never part of a shared build."}},{"@type":"Question","name":"Does the build contain the author's API keys?","acceptedAnswer":{"@type":"Answer","text":"No. Export carries credential metadata only — the name, the type and a masked hint — never the secret itself. The Credentials Vault is write-only over the API by design."}}]}} ``` --- --- title: "WordPress Integrations" description: "Ready-made WordPress webhook integrations for HubSpot, n8n, Slack, Google Sheets and more. Import a working build into your site in one file — with retries, logging and replay built in." url: "https://wpwebhooks.org/integrations/" --- [WP Webhooks](https://wpwebhooks.org/) / Integrations # WordPress integrations Real, working integrations between WordPress and the services you already use — published by the people who built them, and importable into your own site in one file. Webhook Record newly published posts into Airtable New post lands as a row in an Airtable base. No plugin dependency. Built with AI #airtable#posts ♥ 1 [Open →](https://wpwebhooks.org/integrations/record-newly-published-posts-into-airtable/) /Publish ## Publish your integration and get 50 AI credits, +20 for each like. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Get Pro →](https://wpwebhooks.org/pricing/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"CollectionPage","name":"WordPress Integrations","description":"Ready-made WordPress webhook integrations for HubSpot, n8n, Slack, Google Sheets and more. Import a working build into your site in one file — with retries, logging and replay built in.","url":"https://wpwebhooks.org/integrations/","publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"ListItem","position":1,"name":"Record newly published posts into Airtable","url":"https://wpwebhooks.org/integrations/record-newly-published-posts-into-airtable/"}]}} ``` --- --- title: "Create a WordPress user from a Contact Form 7 submission — Automation" description: "A Contact Form 7 submission is mapped onto the WordPress REST API and creates a user account. Code Glue derives the username from the email address and…" url: "https://wpwebhooks.org/automations/create-a-wordpress-user-from-a-contact-form-7-submission/" date: "2026-07-31" --- [WP Webhooks](https://wpwebhooks.org/) / [Automations](https://wpwebhooks.org/automations/) / Create a WordPress user from a Contact Form 7 submission Webhook [Built with AI — read the conversation ↓](#the-conversation) #code-glue#contact-form-7 # Create a WordPress user from a Contact Form 7 submission Every Contact Form 7 submission becomes a WordPress user. The webhook posts to the site's own REST API (`/wp/v2/users`) using a vault credential, so no password ever travels in the build. Field mapping carries `your-email` and `your-name` across; a pre-dispatch Code Glue snippet fills in the two fields the REST endpoint requires but the form does not collect — a username derived from the email address, and a generated password. **Before importing:** point the endpoint at your own site, create a credential for a user who can create accounts, and check that the form field names match your form. / The conversation This build was not assembled by hand. Its author described what they wanted in [Build with AI](https://wpwebhooks.org/docs/build-with-ai/), and the agent read the site, planned the work and applied it. This is that conversation. model gemini-flash-latest via hosted AI credits Author lets create new user from cf7 submission, use WP rest APi to create that user Build with AI Checking the captured payload for Contact Form 7 submissions, the WP REST API schema for user creation, and existing credentials... Build with AI I've analyzed the Contact Form 7 captured payload and the REST API requirements for creating users (`/wp/v2/users`). Plan Overview: 1. **Create Webhook**: Target `https://example.com/wp-json/wp/v2/users` on `wpcf7_mail_sent` (asynchronous mode). 2. **Assign Auth Credential**: Use existing internal WP REST API credential (ID: 29). 3. **Map Payload Fields**: Map `your-email` to `email` and `your-name` to `name`. 4. **Code Glue Snippet**: Automatically generate the required `username` (from email prefix) and a secure random `password` prior to dispatch. 5. **Preview & Assign Snippet**: Attach the snippet to pre-dispatch. 6. **Test Dispatch**: Run a test submission to verify end-to-end integration. Published by the author on purpose. Tool output and raw payloads never travel with a shared build, and secrets never leave the site they were created on. / Abilities used (7) 1. create\_webhook Create webhook pointing to local WP REST API user endpoint 2. assign\_credential Attach internal REST API credential (ID 29) to the webhook 3. set\_mapping Map CF7 email and name fields to WP User arguments 4. create\_snippet Create pre-dispatch PHP snippet to generate required username and password 5. preview\_snippet Preview pre-dispatch snippet transformation against captured payload 6. assign\_snippet Assign pre-dispatch snippet to webhook trigger 7. test\_dispatch Execute test dispatch to verify user creation in WordPress Each of these is a real ability the agent can call — see [the docs](https://wpwebhooks.org/docs/) for what each one does. / What runs Create WP User from CF7 Submission WordPress fires wpcf7\_mail\_sent → Field mapping 2 fields mapped → Code Glue Generate Username & Password for WP User Creation before sending → Queued, then sent POST https://example.com/wp-json/wp/v2/users with your credential Every attempt is retried on its own schedule and written to the delivery log. WordPress fires the action `wpcf7_mail_sent`, the payload is mapped and sent as POST to `example.com`. The delivery is queued and sent in the background, so the visitor never waits. Along the way a Code Glue snippet shapes the payload first and authentication comes from a credential you supply on import. / How it is configured Create WP User from CF7 Submission wpcf7\_mail\_sent / Field mapping WordPress payload Sent as args.0.submission.fields.your-email ──▶↓ email args.0.submission.fields.your-name ──▶↓ name / Code Glue before sending — Generate Username & Password for WP User Creation ``` if (!empty($payload['email'])) { if (empty($payload['username'])) { $payload['username'] = strstr($payload['email'], '@', true) ?: $payload['email']; } if (!empty($payload['username'])) { $payload['username'] = str_replace(' ', '-', $payload['username']); $payload['username'] = preg_replace('/[^A-Za-z0-9\-]/', '', $payload['username']); } if (!empty($payload['name'])) { $payload['name'] = str_replace(' ', '-', $payload['name']); $payload['name'] = preg_replace('/[^A-Za-z0-9\-]/', '', $payload['name']); } if (empty($payload['password'])) { $payload['password'] = wp_generate_password(16, true); } } return $payload; ``` Read any snippet before you import it — Code Glue runs PHP on your site. / Authentication This build authenticates with a credential you supply on import. The shared file carries the name and type only — never the secret. - WP REST API (internal) — admin — basic / Use this build Download the file, then in WordPress open Webhook Actions → Webhooks → Import and drop it in. Anything needing authentication asks you to pick or create a credential as it imports — see [Import & Export](https://wpwebhooks.org/docs/import-export/) for what travels with a build and what does not. A like sends 20 AI credits to whoever published this. / Published by Flow Systems [Website ↗](https://wpwebhooks.org) / Questions Do I need a paid plan for this build? + No. Everything a build can contain — webhooks, triggers, field mapping, conditions, retries, chains and Code Glue snippets — is in the free plugin from 3.0.0 on. A paid plan buys hosted AI credits and external cron, neither of which an imported build needs. How do I use it on my own site? + Download the JSON, then in WordPress open Webhook Actions → Webhooks → Import and drop the file in. Anything that needs authentication asks you to pick or create a credential during the import — secrets are never part of a shared build. Does the build contain the author's API keys? + No. Export carries credential metadata only — the name, the type and a masked hint — never the secret itself. The Credentials Vault is write-only over the API by design. /Publish ## Publish your automation and get 50 AI credits, +20 for each like. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Get Pro →](https://wpwebhooks.org/pricing/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","headline":"Create a WordPress user from a Contact Form 7 submission","description":"A Contact Form 7 submission is mapped onto the WordPress REST API and creates a user account. Code Glue derives the username from the email address and…","url":"https://wpwebhooks.org/automations/create-a-wordpress-user-from-a-contact-form-7-submission/","datePublished":"2026-07-31T14:30:53+00:00","author":{"@type":"Person","name":"Flow Systems"},"isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"},"mainEntity":{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Do I need a paid plan for this build?","acceptedAnswer":{"@type":"Answer","text":"No. Everything a build can contain — webhooks, triggers, field mapping, conditions, retries, chains and Code Glue snippets — is in the free plugin from 3.0.0 on. A paid plan buys hosted AI credits and external cron, neither of which an imported build needs."}},{"@type":"Question","name":"How do I use it on my own site?","acceptedAnswer":{"@type":"Answer","text":"Download the JSON, then in WordPress open Webhook Actions → Webhooks → Import and drop the file in. Anything that needs authentication asks you to pick or create a credential during the import — secrets are never part of a shared build."}},{"@type":"Question","name":"Does the build contain the author's API keys?","acceptedAnswer":{"@type":"Answer","text":"No. Export carries credential metadata only — the name, the type and a masked hint — never the secret itself. The Credentials Vault is write-only over the API by design."}}]}} ``` --- --- title: "New WordPress post to LinkedIn, X and Facebook — Automation" description: "Sends every newly published WordPress post to one n8n webhook, which fans it out to LinkedIn, X (Twitter) and Facebook — retried, logged and replayable." url: "https://wpwebhooks.org/automations/new-wordpress-post-to-linkedin-x-and-facebook/" date: "2026-08-11" --- [WP Webhooks](https://wpwebhooks.org/) / [Automations](https://wpwebhooks.org/automations/) / New WordPress post to LinkedIn, X and Facebook Webhook [Built with AI — read the conversation ↓](#the-conversation) #code-glue#n8n#posts # New WordPress post to LinkedIn, X and Facebook Every time you publish a WordPress post, this webhook sends it to one **n8n** webhook, which fans it out to **LinkedIn**, **X (Twitter)** and **Facebook** — three networks from one delivery, no per-network plugin and no Zapier task cost. ## Why it goes through n8n None of the three social APIs accept a plain API-key POST — LinkedIn, X and Facebook all require OAuth on your app's behalf, and an OAuth dance is not something a webhook can perform. n8n holds those connections, so WordPress only ever talks to one URL. That URL is the only thing you maintain. Add Mastodon or Bluesky later and nothing changes in WordPress. ## What you need first 1. In n8n, add a **Webhook** node — method `POST`, path of your choosing — and copy its **production** URL. 2. Wire a **LinkedIn**, an **X** and a **Facebook Pages** node after it, connecting each account inside n8n. 3. Protect the node with header auth, and store that token in the Credentials Vault. The import asks you for it; it is never part of a shared build. ## What gets sent ``` { "title": "Hello world", "summary": "A short excerpt.", "url": "https://example.com/hello-world/", "image_url": "https://example.com/wp-content/uploads/2026/08/hero.jpg" } ``` `publish_post` hands over the post ID and the `WP_Post` object, so `title` and `summary` map straight across. The permalink and the featured image are **not** in that payload, so a pre-dispatch Code Glue snippet resolves them with `get_permalink()` and `get_the_post_thumbnail_url()`, falling back to `get_the_excerpt()` when the excerpt is empty. Nothing else travels — unmapped fields are dropped, so the rest of the post object stays in WordPress. ## It only fires on a real publish `publish_post` is not a "new post" hook. WordPress fires it on **every** save where the status is `publish`, so editing a live article would announce it a second time. A condition on the old status — `args.2` must not equal `publish` — keeps this to genuine first publications. ## If a delivery fails Delivery is asynchronous, so publishing a post never waits on n8n. A failed POST is retried 5 times — 1m, 2m, 4m, 8m — and every attempt is kept in the delivery log with the full request and response. If n8n was down for the whole ladder, hit **Replay** once it's back and the post goes out without republishing it. > A `200` from the webhook node only means n8n accepted the payload. Check n8n's own execution list to confirm all three social nodes succeeded. / The conversation This build was not assembled by hand. Its author described what they wanted in [Build with AI](https://wpwebhooks.org/docs/build-with-ai/), and the agent read the site, planned the work and applied it. This is that conversation. model gemini-flash-latest via hosted AI credits Author When new post is published lets fan out LinkedIn, X and Facebook posts. This will be done via n8n https://n8n.flowsystems.pl/webhook/30f81536-58e9-4e1d-95ab-38c63e948215 We need to send title, url, excerpt named as summary and featured image url as image\_url Build with AI Checking the captured payload for post publishing to map title, URL, excerpt, and featured image correctly... Build with AI I will set up an integration to trigger your n8n workflow whenever a WordPress post is published. Here is the plan: 1. **Create Webhook**: Disabled webhook pointing to your n8n URL for `publish_post` events. 2. **Field Mapping**: Map `args.1.ID` to `post_id`, `post_title` to `title`, and `post_excerpt` to `summary`. 3. **Code Glue Snippet**: Use a pre-dispatch PHP snippet to resolve `url` (permalink) and `image_url` (featured image) using standard WordPress functions, with a fallback for `summary` if empty. 4. **Preview & Assign**: Test the snippet against captured payload and attach it to the webhook. 5. **Test Dispatch**: Send a live test delivery to verify n8n accepts the payload. 6. **Enable Webhook**: Turn on the integration so future published posts automatically dispatch. Sent by the panel — Fix it Step step\_6 (test\_dispatch) did not succeed: The endpoint rejected the test delivery with 403 — it needs authentication. Attach a credential to the webhook, then retry. The endpoint responded: Authorization data is wrong! Fix the build so this passes — adjust the field mapping or the pre-dispatch Code Glue snippet as needed — then test again before enabling. Build with AI Checking stored credentials to see if an auth header or token for n8n is already available... Build with AI The test dispatch was rejected by n8n with HTTP 403. I will attach your stored **n8n automation** credential (#11) to Webhook #31, send another test dispatch, and enable the webhook once verified. Published by the author on purpose. Tool output and raw payloads never travel with a shared build, and secrets never leave the site they were created on. / Abilities used (9) 1. create\_webhook Create disabled webhook for publish\_post pointing to n8n 2. set\_mapping Map post ID, title, and excerpt for n8n payload 3. create\_snippet Create PHP snippet to inject URL, featured image URL, and summary 4. preview\_snippet Preview the payload transformation with the Code Glue snippet 5. assign\_snippet Attach the pre-dispatch snippet to the webhook 6. test\_dispatch Send test delivery to n8n webhook failed 7. assign\_credential Attach stored n8n credential (#11) to Webhook #31 8. test\_dispatch Retry test delivery with authentication header 9. enable\_webhook Enable Webhook #31 to go live Each of these is a real ability the agent can call — see [the docs](https://wpwebhooks.org/docs/) for what each one does. / What runs Social Media Fan-out via n8n WordPress fires publish\_post → Only when 1 condition matches → Field mapping 3 fields mapped → Code Glue Social Media Payload Enrichment (Permalink & Featured Image) before sending → Queued, then sent POST https://n8n.flowsystems.pl/webhook/30f81536-58e9-4e1d-95ab-38c63e948215 with your credential Every attempt is retried on its own schedule and written to the delivery log. WordPress fires the action `publish_post`, if the conditions below match, the payload is mapped and sent as POST to `n8n.flowsystems.pl`. The delivery is queued and sent in the background, so the visitor never waits. Along the way a Code Glue snippet shapes the payload first and authentication comes from a credential you supply on import. / How it is configured Social Media Fan-out via n8n publish\_post / Only when - args.2 does not equal publish - ↳ otherwise the delivery is skipped, and logged as skipped / Field mapping WordPress payload Sent as args.1.ID ──▶↓ post\_idas number args.1.post\_title ──▶↓ title args.1.post\_excerpt ──▶↓ summary / Code Glue before sending — Social Media Payload Enrichment (Permalink & Featured Image) ``` $postId = $payload['post_id'] ?? null; if ($postId) { $payload['url'] = get_permalink($postId) ?: ''; $imageUrl = get_the_post_thumbnail_url($postId, 'full'); $payload['image_url'] = $imageUrl ?: ''; if (empty($payload['summary'])) { $payload['summary'] = get_the_excerpt($postId) ?: ''; } } unset($payload['post_id']); return $payload; ``` Read any snippet before you import it — Code Glue runs PHP on your site. / Authentication This build authenticates with a credential you supply on import. The shared file carries the name and type only — never the secret. - n8n automation — bearer / Use this build Download the file, then in WordPress open Webhook Actions → Webhooks → Import and drop it in. Anything needing authentication asks you to pick or create a credential as it imports — see [Import & Export](https://wpwebhooks.org/docs/import-export/) for what travels with a build and what does not. A like sends 20 AI credits to whoever published this. / Published by Mateusz [Website ↗](https://flowsystems.pl) / Questions Do I need a paid plan for this build? + No. Everything a build can contain — webhooks, triggers, field mapping, conditions, retries, chains and Code Glue snippets — is in the free plugin from 3.0.0 on. A paid plan buys hosted AI credits and external cron, neither of which an imported build needs. How do I use it on my own site? + Download the JSON, then in WordPress open Webhook Actions → Webhooks → Import and drop the file in. Anything that needs authentication asks you to pick or create a credential during the import — secrets are never part of a shared build. Does the build contain the author's API keys? + No. Export carries credential metadata only — the name, the type and a masked hint — never the secret itself. The Credentials Vault is write-only over the API by design. /Publish ## Publish your automation and get 50 AI credits, +20 for each like. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Get Pro →](https://wpwebhooks.org/pricing/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"TechArticle","headline":"New WordPress post to LinkedIn, X and Facebook","description":"Sends every newly published WordPress post to one n8n webhook, which fans it out to LinkedIn, X (Twitter) and Facebook — retried, logged and replayable.","url":"https://wpwebhooks.org/automations/new-wordpress-post-to-linkedin-x-and-facebook/","datePublished":"2026-08-11T18:54:32+00:00","author":{"@type":"Person","name":"Mateusz"},"image":{"@type":"ImageObject","url":"https://wpwebhooks.org/builds/new-wordpress-post-to-linkedin-x-and-facebook/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk console illustration titled “New WordPress post to LinkedIn, X, and Facebook — automated cross-posting guide”: a glowing WordPress chip is wired by one line labelled “single webhook post” into an n8n chip marked “OAuth secure connection”, which fans out to LinkedIn, X and Facebook tiles labelled “managed OAuth connections”."},"isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"about":{"@type":"SoftwareApplication","name":"Webhook Actions","applicationCategory":"WordPress Plugin"},"mainEntity":{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Do I need a paid plan for this build?","acceptedAnswer":{"@type":"Answer","text":"No. Everything a build can contain — webhooks, triggers, field mapping, conditions, retries, chains and Code Glue snippets — is in the free plugin from 3.0.0 on. A paid plan buys hosted AI credits and external cron, neither of which an imported build needs."}},{"@type":"Question","name":"How do I use it on my own site?","acceptedAnswer":{"@type":"Answer","text":"Download the JSON, then in WordPress open Webhook Actions → Webhooks → Import and drop the file in. Anything that needs authentication asks you to pick or create a credential during the import — secrets are never part of a shared build."}},{"@type":"Question","name":"Does the build contain the author's API keys?","acceptedAnswer":{"@type":"Answer","text":"No. Export carries credential metadata only — the name, the type and a masked hint — never the secret itself. The Credentials Vault is write-only over the API by design."}}]}} ``` --- --- title: "WordPress Automations" description: "Ready-made WordPress automations: multi-step webhook chains, conditional deliveries and flows that write back into WordPress. Import a working build into your site in one file." url: "https://wpwebhooks.org/automations/" --- [WP Webhooks](https://wpwebhooks.org/) / Automations # WordPress automations Multi-step automations built on WordPress events — chains that fan out, conditional deliveries, and flows that write back into WordPress itself. Webhook New WordPress post to LinkedIn, X and Facebook Sends every newly published WordPress post to one n8n webhook, which fans it out to LinkedIn, X (Twitter) and Facebook — retried, logged and replayable. Built with AI #n8n#posts ♥ 1 [Open →](https://wpwebhooks.org/automations/new-wordpress-post-to-linkedin-x-and-facebook/) Webhook Create a WordPress user from a Contact Form 7 submission A Contact Form 7 submission is mapped onto the WordPress REST API and creates a user account. Code Glue derives the username from the email address and generates a password before the request is sent. Built with AI #contact-form-7 ♥ 1 [Open →](https://wpwebhooks.org/automations/create-a-wordpress-user-from-a-contact-form-7-submission/) /Publish ## Publish your automation and get 50 AI credits, +20 for each like. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Get Pro →](https://wpwebhooks.org/pricing/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"CollectionPage","name":"WordPress Automations","description":"Ready-made WordPress automations: multi-step webhook chains, conditional deliveries and flows that write back into WordPress. Import a working build into your site in one file.","url":"https://wpwebhooks.org/automations/","publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"ListItem","position":1,"name":"New WordPress post to LinkedIn, X and Facebook","url":"https://wpwebhooks.org/automations/new-wordpress-post-to-linkedin-x-and-facebook/"},{"@type":"ListItem","position":2,"name":"Create a WordPress user from a Contact Form 7 submission","url":"https://wpwebhooks.org/automations/create-a-wordpress-user-from-a-contact-form-7-submission/"}]}} ``` --- --- title: "WordPress Pipedrive Integration: API, Token and Limits" description: "A WordPress Pipedrive integration on the REST API: the x-api-token header, search-then-create for persons and leads, and the token budget behind every call." url: "https://wpwebhooks.org/blog/wordpress-pipedrive-integration/" date: "2026-09-10" --- # WordPress Pipedrive Integration: API, Token and Limits **TL;DR:** A WordPress Pipedrive integration is two API calls and one budget you have to understand before you ship. - Authenticate with the account's **API token** in an `x-api-token` header. It is tied to one user, and only one is active at a time. - Search the person by email first, create it only if the search is empty, then create the **lead** that references it. That order is what keeps the CRM free of duplicates. - Pipedrive meters calls in **tokens**: a daily budget of 30,000 × plan multiplier × seats, plus a burst limit per two-second window. A search costs 40 tokens; an update costs 10. - Never make these calls inside the request the visitor is waiting on. Queue them. / Official ## Is there an official Pipedrive WordPress plugin? One, and it does something narrower than the phrase suggests. Pipedrive publishes the [LeadBooster Chatbot](https://wordpress.org/plugins/leadbooster-by-pipedrive/) on WordPress.org: it embeds Pipedrive's chatbot on your pages, qualifies visitors in conversation and drops them into your pipeline as deals. It requires the paid LeadBooster add-on on your Pipedrive account, and it captures only what the chatbot asks for. It does not watch your forms, your orders or your user registrations. Everything else on the market is third-party. Form builders such as [WPForms](https://wpforms.com/features/pipedrive-addon/) sell a Pipedrive add-on for their own forms; connector plugins map a handful of form plugins to the persons and deals endpoints. Each covers one source. If the event you care about is a WooCommerce order, a membership renewal or a custom post type, or if your form plugin is not on the add-on's list, you are back to the REST API, which is what this article covers. / Auth ## How does a WordPress site authenticate to the Pipedrive API? With a per-user API token, sent in the `x-api-token` request header.¹ Every user in a Pipedrive account has one, found under the personal preferences, and Pipedrive's [authentication guide](https://pipedrive.readme.io/docs/core-api-concepts-authentication) states the two constraints that matter for a server integration: the token is tied to a specific user and company, and only one active token can exist for that user at a time. Regenerate it and every integration using the old one stops. The base URL carries the company subdomain: `https://{company}.pipedrive.com/api/v2/`. OAuth 2.0 exists as well, but it is the path for apps listed on the Pipedrive Marketplace, where the token belongs to whoever installs the app. For your own site talking to your own account, the API token is the documented choice and the simpler one. Two consequences follow from "tied to one user". First, the records you create are owned by that user unless you pass `owner_id`, so a token from the sales director makes the sales director the owner of every web lead. Create a dedicated integration user. Second, if that user is deactivated when they leave the company, the token dies with them, and the integration fails with a `401` on a Tuesday months later. Treat a `401` as "stop and alert", not "retry". FIG 01 — Search first, then create: the two-call shape that keeps Pipedrive free of duplicates / Objects ## Should a WordPress event create a person, a lead, or a deal? A person and a lead, in that order. Pipedrive's model separates the human from the opportunity: a **person** is the contact record, a **lead** is an unqualified opportunity that sits in the Leads Inbox, and a **deal** is a qualified opportunity in a pipeline stage. A website form or a first order is, by definition, unqualified, so it belongs in the inbox where a salesperson triages it, not in a pipeline stage where it skews the forecast. The lead endpoint enforces this. [POST /api/v1/leads](https://developers.pipedrive.com/docs/api/v1/Leads) requires a `title` and either a `person_id` or an `organization_id`, so you cannot create a lead without a person to attach it to. That is why the person call comes first, and why the search that precedes the person call is the piece most tutorials leave out. The [persons API](https://developers.pipedrive.com/docs/api/v1/Persons) moved to v2, and the v2 shape differs from the old one in a way that breaks copied snippets: `emails` and `phones` are arrays of objects, each with `value`, `primary` and `label`. Sending a plain string for `email` is accepted by nothing. PHP — find or create the person, then the lead ``` function pd_request( $method, $path, $body = null ) { $res = wp_remote_request( 'https://' . PD_COMPANY . '.pipedrive.com' . $path, [ 'method' => $method, 'timeout' => 10, 'headers' => [ 'x-api-token' => PD_TOKEN, 'Content-Type' => 'application/json', ], 'body' => $body ? wp_json_encode( $body ) : null, ] ); if ( is_wp_error( $res ) ) { return $res; } return [ 'code' => wp_remote_retrieve_response_code( $res ), 'body' => json_decode( wp_remote_retrieve_body( $res ), true ), ]; } // 1. Search by email. exact_match=true allows a 1-character term and // returns only full matches. Costs 40 tokens. $q = http_build_query( [ 'term' => $email, 'fields' => 'email', 'exact_match' => 'true', 'limit' => 1 ] ); $found = pd_request( 'GET', '/api/v2/persons/search?' . $q ); $person = $found['body']['data']['items'][0]['item']['id'] ?? null; // 2. Create the person only when the search came back empty. if ( ! $person ) { $created = pd_request( 'POST', '/api/v2/persons', [ 'name' => $name, 'owner_id' => PD_INTEGRATION_USER, 'emails' => [ [ 'value' => $email, 'primary' => true, 'label' => 'work' ] ], 'phones' => $phone ? [ [ 'value' => $phone, 'primary' => true, 'label' => 'work' ] ] : [], ] ); $person = $created['body']['data']['id'] ?? null; } // 3. The lead. Leads are still v1; title + person_id are the required pair. $lead = pd_request( 'POST', '/api/v1/leads', [ 'title' => $name . ' — website enquiry', 'person_id' => $person, 'owner_id' => PD_INTEGRATION_USER, ] ); ``` Three calls when the person is new, two when it already exists. The search is the expensive one at 40 tokens, and it is also the one that makes the whole sequence safe to run twice: a redelivered event finds the person it created the first time and attaches a second lead to it, which a salesperson can merge in seconds. The alternative, a second person record with the same email, is the kind of duplicate that quietly poisons reporting for months. Leads have no built-in duplicate check on the API, so a repeated delivery does produce two leads. If that matters, store the returned lead id against the WordPress record and skip the lead call when it is already set. > Search, then person, then lead. Reverse any two of those and you either cannot create the lead at all or you create a person you already had. — the order of operations / Limits ## What are the Pipedrive API rate limits, and what does a lead actually cost? Pipedrive meters two things at once, and both answer with a `429`.² The first is a **daily token budget**, computed per account: 30,000 base tokens × a plan multiplier × the number of seats, plus any purchased top-ups. The multipliers are 1 for Lite, 2 for Growth, 5 for Premium and 7 for Ultimate. The second is a **burst limit** per two-second window, which for API-token requests is 20 on Lite, 40 on Growth, 100 on Premium and 120 on Ultimate. The search endpoint has its own burst cap of 10 requests per two seconds on every plan. Different operations cost different tokens. Pipedrive publishes costs for some of them: | Operation | Tokens | Where it shows up here | | --- | --- | --- | | Get a single entity | 2 | Reading a person back by id | | Get a list of entities | 20 | Paging through persons | | Update a single entity | 10 | Changing a person | | Delete a single entity | 6 | — | | Search for entities | 40 | The email lookup, every time | | Create a single entity | not published | The person and the lead calls | The create cost is not in Pipedrive's published table, so budget it at the update figure of 10 and check the `x-daily-requests-left` header after your first real day. On that assumption, one new web lead costs 40 + 10 + 10 = **60 tokens**, and a lead for a returning person costs 40 + 10 = **50**. - Lite, one seat: 30,000 × 1 × 1 = 30,000 tokens ÷ 60 = **500 new leads a day** before anything else touches the API. - Growth, three seats: 30,000 × 2 × 3 = 180,000 ÷ 60 = **3,000 a day**. - Premium, ten seats: 30,000 × 5 × 10 = 1,500,000 ÷ 60 = **25,000 a day**. Five hundred a day sounds like plenty for a contact form until you notice it is shared with every other integration on the account, and that a retry loop with no cap spends the same budget for nothing. The burst limit bites differently: a newsletter that sends 200 people to a landing page in the same minute produces a burst of form submissions, and on Lite the search endpoint allows 10 of them per two seconds. The eleventh gets a `429`, and if your code treats that as a failure rather than a "wait", the lead is gone. Every response carries `x-ratelimit-limit`, `x-ratelimit-remaining` and `x-ratelimit-reset` for the burst window. Read them. A `429` is the one status where retrying later is exactly right; a `400` from a malformed `emails` array will fail the same way on every attempt and only burns tokens. ![Cyberpunk illustration of two mismatched figures hauling on separate levers in a bank of heavy interlocked hand levers, the nearest one looking down the frame towards the other lever rather than at his own hands while a counter wheel runs down beside him.](https://wpwebhooks.org/blog/wordpress-pipedrive-integration/og_image.jpg) / Failure ## Should the Pipedrive calls run inside the WordPress request? No, and the reason is sharper here than with most APIs because there are two or three sequential calls, not one. A form submission that waits for a search, then a create, then a lead is waiting on three round trips to Pipedrive's servers, and with a 10-second timeout on each, a slow afternoon at Pipedrive becomes a 30-second form for your visitor. PHP-FPM workers pile up behind it, and the burst limit above turns a traffic spike into a queue of visitors staring at a spinner. Record the intent when the hook fires, return to the visitor immediately, and let a background worker own the three calls. That worker is also where the `429` handling belongs: it can sleep until `x-ratelimit-reset` and try again, which a synchronous request never can. The trade is that the lead appears in the inbox a few seconds later rather than instantly, which no salesperson has ever noticed. | Concern | Hand-rolled wp\_remote\_request | Webhook Actions | | --- | --- | --- | | Pipedrive connector | None — you write the three calls | Also none. It delivers a mapped payload to a URL you name; the person and lead logic still lives at the receiving end or in a chain step | | Delivery timing | Inline — the visitor waits for three round trips | Queued — the request returns before the first call | | A 429 from the burst window | Lost unless you wrote the wait | Retried with exponential backoff, 5 attempts, capped at an hour | | A 400 from a bad field | Retried forever if you loop naively | Marked failed at once — a bad payload is not retried | | Evidence of what was sent | Whatever you remembered to log | Per-attempt request and response, with replay | | The API token | A constant in wp-config or a plugin option | Encrypted in the Credentials Vault, referenced by id, never returned to any caller | The honest read of that table: the right-hand column is not a Pipedrive integration off a shelf. There is no connector catalogue, and the search-then-create sequence is still yours to design, either as a chain of steps or at the endpoint that receives the payload. What it removes is the queue, the backoff curve, the attempt log and the secret storage, which is the part that takes a week and the part people skip. The secret row is the one worth spelling out, and Pipedrive is the easy case. A per-user API token is a static value in a header you name, which is exactly the shape the plugin's **Credentials Vault** holds: you add it once on the Credentials Vault screen, or with a `POST` to `/wp-json/fswa/v1/credentials`, pick the API key type with `x-api-token` as the header name, and the webhook then references it by id. The value is encrypted at rest, injected only at dispatch and redacted out of the delivery log, and it never comes back over the API — a read returns a masked hint, so the token is absent from the webhook config, the export file and anything an AI assistant can see. A CRM that authenticates with an OAuth exchange rather than a static token, [Zoho for instance](https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/), does not get this for free. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Exposure ## What does Pipedrive not protect you from? Three things, and none of them show up in a successful test. **The token is a person, not a service.** There is no service account in the API-token model. The token inherits that user's visibility and permissions in full, and it dies when the user is deactivated. Create a dedicated integration user, give it the permission set it needs and nothing more, and set `owner_id` explicitly so web leads land with the right rep rather than with the integration user. **A public form is an unauthenticated write path into the CRM.** Anything that turns a submission into a person and a lead has handed the internet a way to create both, and each attempt spends 60 tokens of a budget the sales team shares. Spam does not just create junk records; at volume it exhausts the daily budget and every other integration on the account starts receiving `429`s. Gate the form before the queue, not after. **Retries multiply leads, not just persons.** The search makes the person call idempotent. Nothing makes the lead call idempotent, so a delivery that succeeded but timed out on the response, then retried, produces two leads for one enquiry. Store the lead id on the WordPress side when you get it, and make the retry check for it first. If the destination is a different CRM, the shape is the same and the traps differ: [Salesforce meters calls per org on a rolling day](https://wpwebhooks.org/blog/wordpress-salesforce-integration/), [HubSpot limits requests per ten seconds](https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/), and [Zoho CRM charges credits per ten records](https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/). What they share is the advice in the previous section: never make the call where the visitor is waiting. /Footnotes ¹ Authentication, [Pipedrive Developer Documentation](https://pipedrive.readme.io/docs/core-api-concepts-authentication) — the x-api-token header, the one-active-token rule, and the company-domain base URL. ² Rate limiting, [Pipedrive Developer Documentation](https://pipedrive.readme.io/docs/core-api-concepts-rate-limiting) — the daily token budget formula, plan multipliers, burst limits per two-second window, and the per-operation token costs. Read 2026-09-10. ³ Persons and Leads endpoint references, [developers.pipedrive.com](https://developers.pipedrive.com/docs/api/v1/Persons). ⁴ [LeadBooster Chatbot by Pipedrive](https://wordpress.org/plugins/leadbooster-by-pipedrive/) on WordPress.org — the only plugin published by Pipedrive itself. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Pipedrive Integration: API, Token and Limits","description":"A WordPress Pipedrive integration on the REST API: the x-api-token header, search-then-create for persons and leads, and the token budget behind every call.","datePublished":"2026-09-10","dateModified":"2026-09-10","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-pipedrive-integration/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-pipedrive-integration/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of two mismatched figures hauling on separate levers in a bank of heavy interlocked hand levers, the nearest one looking down the frame towards the other lever rather than at his own hands while a counter wheel runs down beside him."},"keywords":["wordpress pipedrive integration","pipedrive wordpress","wordpress to pipedrive","pipedrive api wordpress","pipedrive wordpress plugin"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Pipedrive Integration: API, Token and Limits","item":"https://wpwebhooks.org/blog/wordpress-pipedrive-integration/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is there an official Pipedrive plugin for WordPress?","acceptedAnswer":{"@type":"Answer","text":"One: the LeadBooster Chatbot by Pipedrive on WordPress.org, which embeds the Pipedrive chatbot and turns conversations into deals. It requires the paid LeadBooster add-on and captures only what the chatbot asks. It does not watch forms, orders or registrations, so those events need the REST API or a third-party connector."}},{"@type":"Question","name":"How does a WordPress site authenticate to the Pipedrive API?","acceptedAnswer":{"@type":"Answer","text":"With a per-user API token sent in the x-api-token request header, against a base URL that includes the company subdomain. The token is tied to one user and company, and only one active token exists per user, so regenerating it stops every integration using the old one. OAuth 2.0 is the path for apps listed on the Pipedrive Marketplace."}},{"@type":"Question","name":"Should a WordPress form create a Pipedrive person, lead or deal?","acceptedAnswer":{"@type":"Answer","text":"A person and then a lead. A lead is an unqualified opportunity in the Leads Inbox, and POST /api/v1/leads requires a title plus a person_id or organization_id, so the person must exist first. Search persons by email with exact_match before creating one, so a repeated submission attaches to the existing person instead of duplicating it."}},{"@type":"Question","name":"What are the Pipedrive API rate limits?","acceptedAnswer":{"@type":"Answer","text":"Two limits, both answered with a 429. A daily token budget of 30,000 base tokens times a plan multiplier (Lite 1, Growth 2, Premium 5, Ultimate 7) times the number of seats. And a burst limit per two-second window: 20 requests on Lite, 40 on Growth, 100 on Premium, 120 on Ultimate for API-token requests, with search capped at 10 per two seconds on every plan."}},{"@type":"Question","name":"How many tokens does creating a Pipedrive lead cost?","acceptedAnswer":{"@type":"Answer","text":"Pipedrive publishes costs for some operations: a search costs 40 tokens, an update 10, a single read 2. The create cost is not in the published table, so budget it at the update figure. On that basis a new web lead costs about 60 tokens for the search, person and lead calls, which is 500 leads a day on a one-seat Lite account before anything else uses the API."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-pipedrive-integration.png","caption":"FIG 01 — Search first, then create: the two-call shape that keeps Pipedrive free of duplicates","description":"A WordPress hook fires and the delivery is queued rather than sent inline. The worker first searches the persons endpoint by email with exact matching. If a person exists, its id is reused; if not, a person is created with the email and phone arrays the v2 API expects. The worker then creates a lead that references the person id. Both writes draw from a daily token budget that Pipedrive computes from plan and seat count, and every call also counts against a burst limit measured over two-second windows. A 429 from either limit is worth retrying later; a 400 from a bad field is not.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "n8n WordPress Trigger: Polling vs Webhooks" description: "There is no n8n WordPress trigger node, so most workflows poll. Where that works, the events it is structurally blind to, and the WP-Cron objection answered." url: "https://wpwebhooks.org/blog/n8n-wordpress-trigger/" date: "2026-09-08" --- # n8n WordPress Trigger: Polling vs Webhooks **TL;DR:** n8n has no WordPress trigger node, so most people poll. That is the right default for posts and orders — and it is structurally blind to most WordPress events. - The official **WordPress node is actions-only** — create, update, retrieve. It does not listen. - Polling works where the REST API exposes a **queryable, date-filterable list**. Posts qualify. Most things do not. - `/wp/v2/users` has **no date filter at all**. You can find new users; you cannot detect an updated one. - Polling reads **state**. A value that changes and changes back between two polls never happened. - The "webhooks don't fire because WP-Cron needs traffic" objection is real, and has four separate fixes — one of which needs no cron at all. / Objection ## Why do n8n users poll WordPress instead of receiving webhooks? Because the platform pushes them that way, and because the usual alternative has a bad reputation. n8n's official [WordPress node](https://n8n.io/integrations/wordpress/) is an action node. It creates posts, updates posts, retrieves posts and users. There is no WordPress trigger node — nothing in the box listens for something happening on your site. So the natural shape of a WordPress workflow in n8n is a Schedule Trigger followed by an HTTP Request or the WordPress node, asking "anything new since last time?" The second reason is the one that comes up in every thread: WordPress webhooks are widely believed to be unreliable, because WP-Cron only runs when somebody visits the site. On a quiet site a queued delivery can sit for hours. That belief is _correct about the default_, and we will deal with it properly further down — but it is worth separating the two questions. "Is push reliable here?" is a configuration question with a known answer. "Can polling see this event at all?" is a structural one, and it does not have an answer. Credit where it is due: for the two cases people usually build first, polling is genuinely the better default. It needs nothing installed on the WordPress side, which matters enormously when the site belongs to a client who will not let you add a plugin. It is stateless and self-healing — if n8n is down for an hour, the next poll simply picks up a wider window. And it backfills: a webhook only ever fires forward, so a workflow built today knows nothing about yesterday. | WordPress event | Pollable? | Why | | --- | --- | --- | | New post published | Yes | /wp/v2/posts supports after and modified\_after | | Post edited | Yes | orderby=modified plus modified\_after | | New WooCommerce order | Yes | /wc/v3/orders exposes date filters | | New user registered | Partly | orderby=registered\_date only — no date filter | | User profile updated | No | no modified field, no date filter, no ordering | | Contact Form 7 submission | No | nothing is stored without an add-on | | Comment approved | No | status transition leaves no queryable trace | | Any custom do\_action | No | no endpoint exists to poll | / Limits ## What can polling not see? Polling is a query against a list. It works when three things are true at once: the thing you care about is stored, that store has a REST endpoint, and that endpoint can be filtered or ordered by a date that changes when the thing happens. Posts satisfy all three, which is why every popular n8n WordPress template is built on them. Users do not. The List Users endpoint accepts `context`, `page`, `per_page`, `search`, `exclude`, `include`, `offset`, `order`, `orderby`, `slug`, `roles`, `capabilities`, `who` and `has_published_posts`. There is no `after`, no `modified_after`, and the permitted `orderby` values stop at `registered_date`. You can therefore find users who registered since your last run, and you have no mechanism whatsoever for finding a user whose profile changed: n8n — the poll that cannot be written ``` // Works: new users since the last run. GET /wp-json/wp/v2/users?orderby=registered_date&order=desc&per_page=20 // then discard everything older than $lastRun in a Code node // Does not exist: users whose profile changed since the last run. GET /wp-json/wp/v2/users?modified_after=2026-09-08T00:00:00 // ^ modified_after is silently ignored. You get page 1 of all users. // The only remaining option is a full-table diff, every run: // fetch every user, hash each record, compare against a stored hash. // 10,000 users x every 5 minutes = 2.88M records/day to notice one edit. ``` That last line is the honest cost of forcing a poll onto an event that was never designed to be polled. It is not that it is impossible — it is that the workaround is a full-table diff on a schedule, and it scales with the size of your site rather than with the number of things that actually happened. The deeper problem is that **polling reads state, not events**. A poll tells you what is true now. It cannot tell you what happened in between. If a post is published and then unpublished inside your five-minute window, your workflow never sees it. If an order moves from processing to completed to refunded between two runs, you observe one transition and miss two. For an audit trail, a notification, or anything where the transition _is_ the business event, that gap is the whole problem. And then there is the class of events that leaves no trace at all. Contact Form 7 does not store submissions — without an add-on such as Flamingo, the data exists only for the duration of the request that carried it. There is nothing to poll, at any interval, ever. The same is true of every custom `do_action` in every plugin on the site: those are the events that make WordPress extensible, and none of them has an endpoint. ![Cyberpunk illustration: in a dim service corridor an augmented operator hauls a hooded scanning head around on a geared mast, one boot braced against its base and her face sealed into its eyepiece, so the single narrow arc of light it throws — falling on one raised shutter with a lit interior — is the only thing she can see; further down the rank behind her turned back, three shutters have already come down, orange light still draining from their seams.](https://wpwebhooks.org/blog/n8n-wordpress-trigger/og_image.jpg) > Polling answers "what is true now?". A webhook answers "what just happened?". Most WordPress events are the second question, and only the first one has an endpoint. — the line between the two approaches / Cron ## But don't WordPress webhooks fail because WP-Cron needs traffic? This is the strongest argument against push and it deserves a straight answer rather than a defence. [WP-Cron is not a scheduler](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/) — it is a task list checked during page loads. If a plugin accepts an event, queues it, and waits for WP-Cron to drain the queue, then on a site with no visitors nothing is drained. The event is not lost, but it is not delivered either, and "delivered eventually, when someone happens to browse the site" is not a property you can build an automation on. There are four ways out of it, and they are worth knowing in order of how little work they take: - **Send synchronously.** The delivery fires inline, inside the request that triggered it, and never touches cron at all. It costs a little latency on that one request and it works on any site from the moment it is switched on. This is the correct default on a low-traffic site, and it is exactly what the objection is asking for. - **Let Action Scheduler take the queue.** If Action Scheduler is present — and it is on every WooCommerce site, because WooCommerce ships it — a queue can be handed to it instead of WP-Cron, which gives persistent job tracking and far better behaviour under load. On a WooCommerce store the problem tends not to arise in the first place. - **Point a real cron at it.** A token-authenticated REST endpoint that a system crontab or a free external pinger hits on a fixed schedule turns "when someone visits" into "every sixty seconds". [External cron services](https://wpwebhooks.org/blog/wordpress-external-cron-services/) covers the free options. - **Use a managed pinger.** The same thing without a crontab, for people who do not have shell access to the server. The distinction that matters for an n8n user evaluating this: the failure mode above is a property of _queued delivery on an idle site_, not of webhooks. Synchronous delivery has no cron in the path. Once that is understood, the reliability question stops being "can WordPress push?" and becomes the ordinary question you would ask of any sender — does it retry, can you see what it sent, and can you replay it when the receiving workflow had a bug? / Choosing ## Which should an n8n workflow actually use? Both, in different places — and on the push side it matters a great deal _what_ is doing the pushing. A hand-rolled `wp_remote_post` inside a hook is a webhook in the same sense that a `curl` in a loop is a delivery system: it works until the receiving end is down. So the honest comparison has three columns, not two. | Concern | Polling from n8n | Raw `do_action` + `wp_remote_post` | Webhook Actions | | --- | --- | --- | --- | | Setup on the WordPress side | Nothing at all — works on sites you do not control | A snippet per event, maintained by you | Install once, then wire events up in wp-admin | | Events it can observe | Only what has a date-filterable REST endpoint | Any `do_action` — one snippet each | Any `do_action`, including custom plugin events | | Transitions and one-off events | Missed if they resolve between two polls | Delivered when they happen | Delivered when they happen | | Latency | Half the poll interval on average | Immediate — inside the request | Immediate synchronously, or seconds when queued | | Load when nothing happens | Every interval, all day, forever | None | None | | Delivery on an idle, low-traffic site | Unaffected — n8n does the polling | Inline works; a WP-Cron queue never drains | Synchronous mode, or Action Scheduler / real cron | | Retrying a failed delivery | Not needed — the next poll re-reads | None — `wp_remote_post` fires once | Automatic on 5xx and 429, exponential backoff, 5 attempts | | Recovering from a broken workflow | Re-run against an earlier window | The payload is gone | Every request and response logged; replay the original event | | Backfilling history from before you set it up | Widen the date window and re-run | Not possible — fires forward only | Not possible — fires forward only | Read the last three rows together and the practical rule falls out. Polling recovers by re-querying, so its resilience is free. A webhook fires once, so _everything_ depends on whether the sender kept the payload — which is why the middle column is not a cheaper version of the third one, it is a different reliability class. A sender that fires and forgets genuinely is worse than a poll. A sender that stores every payload, retries on `5xx` and `429`, and lets you re-send the original event after you fix the workflow is strictly better, because it also sees the events polling cannot reach. So: poll for posts and orders on sites you do not control, and for backfilling anything that happened before the integration existed. Push for form submissions, user changes, status transitions and anything custom — and use something that keeps a delivery log, rather than a snippet that throws the payload away the moment n8n returns a 500. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) /Footnotes ¹ WordPress node operations, [n8n integrations directory](https://n8n.io/integrations/wordpress/). ² List Users query parameters and permitted orderby values, [WordPress REST API Handbook](https://developer.wordpress.org/rest-api/reference/users/). ³ Posts date filters — after, before, modified\_after, modified\_before, [WordPress REST API Handbook](https://developer.wordpress.org/rest-api/reference/posts/). ⁴ WP-Cron is triggered on page load, [WordPress Plugin Handbook](https://developer.wordpress.org/plugins/cron/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"n8n WordPress Trigger: Polling vs Webhooks","description":"There is no n8n WordPress trigger node, so most workflows poll. Where that works, the events it is structurally blind to, and the WP-Cron objection answered.","datePublished":"2026-09-08","dateModified":"2026-09-08","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/n8n-wordpress-trigger/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/n8n-wordpress-trigger/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration: in a dim service corridor an augmented operator hauls a hooded scanning head around on a geared mast, one boot braced against its base and her face sealed into its eyepiece, so the single narrow arc of light it throws — falling on one raised shutter with a lit interior — is the only thing she can see; further down the rank behind her turned back, three shutters have already come down, orange light still draining from their seams."},"keywords":["n8n wordpress trigger","n8n wordpress webhook","trigger n8n from wordpress","n8n wordpress integration","wordpress webhook n8n"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"n8n WordPress Trigger: Polling vs Webhooks","item":"https://wpwebhooks.org/blog/n8n-wordpress-trigger/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does n8n have a WordPress trigger node?","acceptedAnswer":{"@type":"Answer","text":"No. The official WordPress node in n8n is an action node — it creates, updates and retrieves posts, pages and users. Nothing in n8n listens for WordPress events, so workflows either poll on a Schedule Trigger or receive a webhook sent by something installed on the WordPress side."}},{"@type":"Question","name":"Can I poll WordPress from n8n instead of using webhooks?","acceptedAnswer":{"@type":"Answer","text":"Yes, and for posts and WooCommerce orders it is a reasonable default. The /wp/v2/posts endpoint supports after, before, modified_after and modified_before, and orderby=modified, so a Schedule Trigger plus an HTTP Request reliably finds what changed since the last run without installing anything on the site."}},{"@type":"Question","name":"Which WordPress events cannot be detected by polling?","acceptedAnswer":{"@type":"Answer","text":"Anything without a date-filterable REST endpoint. User profile updates cannot be polled at all — /wp/v2/users has no after or modified_after parameter and its orderby values stop at registered_date. Contact Form 7 submissions are not stored without an add-on, so nothing exists to query, and custom do_action events have no endpoint whatsoever."}},{"@type":"Question","name":"Why do WordPress webhooks not fire on low-traffic sites?","acceptedAnswer":{"@type":"Answer","text":"Because a queued delivery usually waits for WP-Cron, which only runs when someone loads a page. On an idle site the queue is never drained. The fix is either synchronous delivery, which fires inline during the triggering request and needs no cron at all, or handing the queue to Action Scheduler, a system crontab or an external pinger."}},{"@type":"Question","name":"Is polling or a webhook better for triggering n8n from WordPress?","acceptedAnswer":{"@type":"Answer","text":"They fail in opposite directions, so it depends on the event. Poll for posts and orders on sites you do not control, since polling needs nothing installed and can backfill history. Push for form submissions, user changes, status transitions and custom hooks, since those either leave no queryable record or resolve between two polls."}}]} ``` --- --- title: "WP Fusion Alternative: When a Webhook Is the Better Fit" description: "An honest WP Fusion alternative guide: what user sync and tag gating actually do, what the tiers cost, and when an outgoing webhook is the better tool." url: "https://wpwebhooks.org/blog/wp-fusion-alternative/" date: "2026-09-07" --- # WP Fusion Alternative: When a Webhook Is the Better Fit **TL;DR:** WP Fusion keeps a WordPress _user_ and a CRM _contact_ in step, and gates content by CRM tag. That is a specific job, and if it is your job, nothing in this article replaces it. - WP Fusion Lite is free and syncs registered users to 60+ CRMs. The paid tiers, from **$297/year**, add WooCommerce, LearnDash, form entries and guest customers. - It is user-centric by design: the unit of sync is a WordPress user matched to a contact id. Guests and anonymous forms need the paid integrations. - An outgoing webhook does a narrower job: when a hook fires, deliver one payload to one URL, retry it, log it. No user record, no tag model, no content gating. - Pick by the question you are asking: "keep this person in sync" is WP Fusion; "tell that system this happened" is a webhook. / Overview ## What does WP Fusion actually do? It connects WordPress user accounts to contact records in a CRM and keeps the two aligned. The free [WP Fusion Lite](https://wordpress.org/plugins/wp-fusion-lite/) on WordPress.org, at 5,000+ active installations, syncs a registered user's name and email to a contact, applies tags at registration, restricts content by CRM tag, imports contacts as users and exports users as contacts. It supports more than sixty CRM and marketing platforms, from ActiveCampaign and HubSpot to Salesforce, Klaviyo and Zoho.¹ The mechanism is worth stating plainly, because it is what everything else follows from. When a user is created or updated, WP Fusion looks up or creates the matching contact, stores the CRM's contact id against the WordPress user, and pushes the mapped profile fields across. Tags flow the other way: the CRM sends a [webhook back to the site](https://wpfusion.com/documentation/webhooks/about-webhooks/) when a contact is tagged, and WP Fusion updates the user's tags, which its access rules then read to decide what that user may see. The unit of work is always _a person with a WordPress account_. The paid tiers extend that to things a Lite install cannot see. Per the listing, Lite does not apply tags based on purchases or course activity, does not sync form entries, does not sync ecommerce customers, and does not detect data from third-party plugins. Those are the WooCommerce, LearnDash, Gravity Forms and Elementor integrations, and they are the reason most people pay. / Pricing ## What does WP Fusion cost? Four tiers, all annual, on the [pricing page](https://wpfusion.com/pricing/) as read on 2026-09-10:² | Plan | Price | Sites | What it adds | | --- | --- | --- | --- | | Lite | Free | Unlimited | User sync, tags at registration, content gating | | Personal | $297 / year | 1 site + subdomains | The plugin integrations: WooCommerce, LearnDash, forms, 100+ others | | Plus | $427 / year | 1 site + subdomains | Six add-ons: Enhanced Ecommerce, Abandoned Cart, Media Tools, Login Redirects, Zapier, Event Tracking | | Professional | $647 / year | Unlimited | Everything in Plus, across as many sites as you run | | Lifetime | $1,999 once | Unlimited | Professional, paid once | The arithmetic that usually decides it: $297 ÷ 12 = **$24.75 a month** for one site. An agency running three client sites cannot buy three Personal licences cheaper than one Professional, because 3 × $297 = $891 against $647, so the third site is where Professional starts paying for itself. The Lifetime price is 1,999 ÷ 647 = **3.1 years** of Professional, so it is a bet that you will still be running WP Fusion in 2030. There is a 30-day refund window. None of that is expensive for what it does, if what it does is what you need. The rest of this article is about working out whether it is. FIG 01 — Two different jobs: a user record kept in sync, or an event delivered once / Where it wins ## When is WP Fusion clearly the right tool? When the thing you are managing is a _relationship_ rather than an _event_. Concretely, it wins when: - **CRM tags decide what a user can see.** A course unlocked when a tag is applied in ActiveCampaign, a members-only page gated on a HubSpot list, a login redirect driven by segment. Nothing else in the WordPress ecosystem does this as completely, and a webhook plugin does not do it at all. - **The sync is two-way.** A rep changes a contact's phone number in the CRM and the WordPress profile updates; a user edits their profile and the CRM follows. That needs the contact id stored on both ends and a webhook coming back in, which is exactly what WP Fusion maintains. - **Your CRM is one of the sixty.** Each connector already knows that platform's field model, tag model and quirks. Building that yourself against Salesforce or Zoho is a week of work per CRM, and this site has the [Salesforce](https://wpwebhooks.org/blog/wordpress-salesforce-integration/) and [Zoho](https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/) walkthroughs to prove it. - **A marketer maintains it.** Field mapping and tag rules live in the admin, legible to someone who does not write PHP. ![Cyberpunk illustration of an augmented woman standing astride a geared junction with one boot on each of two ways as they rotate apart, each way lit by its own identical lamp, her stance already wider than she can hold.](https://wpwebhooks.org/blog/wp-fusion-alternative/og_image.jpg) / Where it does not ## When is a webhook the better fit? When the question is "tell that system this happened" rather than "keep this person in sync". The difference sounds subtle and is not: - **There is no user.** A guest checkout, an anonymous contact form, a comment, a newsletter signup with no account. WP Fusion's unit of sync is a WordPress user; the paid tiers reach guests through specific plugin integrations, but the model still wants a person to attach the contact id to. A webhook fires on the hook whether or not anyone is logged in. - **The destination is not a CRM.** An n8n workflow, a Slack channel, a Google Sheet, a partner's API, your own microservice, a data warehouse. Sixty CRMs is a catalogue, and a catalogue can be absent from. A webhook has no catalogue: you supply the URL. - **You need the event, not the state.** "Order 4,812 moved to _completed_ at 14:03" is an event with a payload. A sync tool reduces that to "this contact now has the _customer_ tag", which is the right abstraction for gating content and the wrong one for feeding an order pipeline, an invoicing system or an analytics stream. - **The hook is not in anyone's list.** A custom post type going live, a plugin-specific action, a `do_action` you added yourself. A webhook plugin that accepts any hook name as a trigger reaches those on the day they exist. Side by side, then. The first row is the one that should decide it: | Concern | WP Fusion | Webhook Actions | | --- | --- | --- | | Tag-based content access | Its core strength — CRM tags gate pages, courses and redirects | Not what it does. Outgoing only; nothing comes back to gate anything | | Two-way user sync | Yes — contact id stored, incoming webhooks update tags and fields | No. One direction: WordPress event out to a URL | | CRM connectors | 60+ platforms with field and tag models built in | None. You point it at an endpoint you name and map the payload yourself | | Guests and anonymous forms | Paid integrations for WooCommerce, forms and ecommerce customers | Any hook, logged in or not — no user record required | | Non-CRM destinations | Zapier add-on on Plus and above | Any HTTPS endpoint — n8n, Slack, Sheets, your own service | | Arbitrary hooks | The integrations it ships | Any do\_action name, typed in or picked from a captured payload | | Delivery guarantees | Documentation warns webhooks are resource-heavy and duplicates can corrupt data | Queued, exponential backoff 1m, 2m, 4m, 8m over 5 attempts, per-attempt log with replay | | Price | Lite free; $297 to $647 a year for the integrations | Free, GPL | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / The alternative ## What does the webhook route actually look like? [Webhook Actions](https://wpwebhooks.org/wordpress-webhook-plugin/) is the free plugin behind this site, and the honest comparison is that it is not a CRM sync tool and does not try to be one. You pick a WordPress hook, map the fields against a payload captured from your own site, and point it at a URL. Delivery is queued rather than inline, retried with exponential backoff over five attempts, and every attempt is logged with its request, response and a replay button. Conditions decide whether a given event is sent at all, so "only completed orders over $100" is a rule rather than code. Multi-step is **Webhook Chains**: a webhook completing with a 2xx fires the next one, handing it the upstream response alongside the original payload. That is how the "search, then create, then attach" sequence a CRM usually wants gets built without writing the sequence in PHP, and it is where the [Pipedrive walkthrough](https://wpwebhooks.org/blog/wordpress-pipedrive-integration/) ends up. Where it is genuinely weaker, and this should decide it: **there is no connector catalogue and nothing comes back.** If you need a HubSpot tag to unlock a LearnDash course, a webhook plugin has no opinion about HubSpot, no idea what a tag is, and no way to hear about it. That is WP Fusion's job, and it is a good one. / Both ## Can you run both? Yes, and plenty of sites should. WP Fusion owns the relationship: the contact, the tags, the gating. A webhook owns the events the CRM was never the right home for: the order line items to the fulfilment API, the support ticket to the helpdesk, the new post to the social scheduler, the failed payment to the on-call channel. The two do not overlap because they answer different questions, and the mistake this article exists to prevent is buying one to do the other's job. The one place they collide is a form. A form submission can be "create a contact and tag it" (WP Fusion's paid form integrations) or "send this entry to a URL" (a webhook), and if the destination is a CRM in the catalogue, the sync tool is simpler. If the destination is anything else, or the CRM is not in the catalogue, the webhook is. / Verdict ## Which one should you actually pick? **WP Fusion**, when CRM tags drive what users can see, when the sync has to run both ways, or when your CRM is one of its sixty and you want the field model handled for you. Rebuilding that on webhooks would be a worse answer at any price. **A webhook plugin**, when the event has no user behind it, when the destination is not a CRM, when you need the event rather than the resulting state, or when the hook is one nobody built a connector for. **Both**, when your site has a membership and an operation. Most sites that outgrow a contact form do. /Footnotes ¹ Feature list, install count and the Lite limitations from the [WP Fusion Lite listing on WordPress.org](https://wordpress.org/plugins/wp-fusion-lite/), read 2026-09-10 (version 3.47.14). ² Plan prices, site counts and add-ons from [wpfusion.com/pricing](https://wpfusion.com/pricing/), read 2026-09-10. Prices change; check before quoting. ³ Incoming webhook actions, resource cost and the duplicate and loopback warnings from [About Webhooks](https://wpfusion.com/documentation/webhooks/about-webhooks/) in the WP Fusion documentation. ⁴ Action hooks, [WordPress Plugin Handbook](https://developer.wordpress.org/plugins/hooks/actions/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WP Fusion Alternative: When a Webhook Is the Better Fit","description":"An honest WP Fusion alternative guide: what user sync and tag gating actually do, what the tiers cost, and when an outgoing webhook is the better tool.","datePublished":"2026-09-07","dateModified":"2026-09-07","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wp-fusion-alternative/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wp-fusion-alternative/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of an augmented woman standing astride a geared junction with one boot on each of two ways as they rotate apart, each way lit by its own identical lamp, her stance already wider than she can hold."},"keywords":["wp fusion alternative","wp fusion","wp fusion pricing","wp fusion lite","wp fusion vs webhook"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WP Fusion Alternative: When a Webhook Is the Better Fit","item":"https://wpwebhooks.org/blog/wp-fusion-alternative/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What does WP Fusion do?","acceptedAnswer":{"@type":"Answer","text":"It keeps WordPress user accounts and CRM contact records in sync. It stores the contact id against the user, pushes profile fields to the CRM, receives tags back through an incoming webhook, and uses those tags to restrict content, redirect logins and unlock courses. It supports more than sixty CRM and marketing platforms."}},{"@type":"Question","name":"Is WP Fusion Lite free?","acceptedAnswer":{"@type":"Answer","text":"Yes. WP Fusion Lite is on WordPress.org with 5,000+ active installations and syncs registered users, applies tags at registration and gates content by tag. It does not apply tags from purchases or course activity, sync form entries, sync ecommerce customers or read data from third-party plugins. Those need a paid licence."}},{"@type":"Question","name":"How much does WP Fusion cost?","acceptedAnswer":{"@type":"Answer","text":"As of September 2026: Personal at $297 per year for one site, Plus at $427 per year for one site with six add-ons, Professional at $647 per year for unlimited sites, and a Lifetime licence at $1,999. All come with a 30-day refund window. Prices change, so check the pricing page before quoting."}},{"@type":"Question","name":"When is a webhook a better choice than WP Fusion?","acceptedAnswer":{"@type":"Answer","text":"When there is no WordPress user behind the event, when the destination is not a CRM, when you need the event itself rather than the resulting contact state, or when the hook is one nobody built a connector for. A webhook delivers one payload to one URL when a hook fires, and it does not need an account, a tag model or a catalogue."}},{"@type":"Question","name":"Can WP Fusion and a webhook plugin run on the same site?","acceptedAnswer":{"@type":"Answer","text":"Yes, and many sites should. WP Fusion owns the relationship: the contact, the tags and the content gating. A webhook owns the events the CRM was never the right home for, such as order line items to a fulfilment API or a new post to a social scheduler. They answer different questions and do not overlap."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wp-fusion-alternative.png","caption":"FIG 01 — Two different jobs: a user record kept in sync, or an event delivered once","description":"WP Fusion keeps a WordPress user and a CRM contact in step. It stores the contact id against the user, pushes profile fields to the CRM, pulls tags back through an incoming webhook, and uses those tags to decide what the user may see. An outgoing webhook does a narrower job: when a hook fires, it delivers one payload to one URL, retries it on failure, and logs the attempt. Nothing comes back and no user record is required, which is why it fits guest checkouts and anonymous forms that a user-centric sync cannot see.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Zoho CRM Integration: OAuth, Upsert, Limits" description: "A WordPress Zoho CRM integration on the v8 API: Self Client OAuth, the refresh-token cap, per-datacentre hosts, and the upsert that never duplicates a lead." url: "https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/" date: "2026-09-05" --- # WordPress Zoho CRM Integration: OAuth, Upsert, Limits **TL;DR:** A WordPress Zoho CRM integration is one OAuth exchange you do by hand, one refresh token you guard, and one upsert call that never creates a duplicate. - Use a **Self Client** in the Zoho API Console. The grant code it issues lives 3 to 10 minutes; exchange it once and keep the **refresh token**, which never expires until revoked. - Access tokens last **one hour**, and you may mint at most **10 per refresh token in 10 minutes**. Refreshing on every request breaks after ten requests. - Call the `api_domain` the token response gives you. EU, India, Australia, Japan, China and Canada accounts live on different hosts. - Write with `/crm/v8/Leads/upsert`. It checks `Email` by default and tells you whether it inserted or updated. - Every ten records cost **one API credit**. The Free edition has 5,000 a day. / Official ## Is there an official Zoho CRM WordPress plugin? Yes, and it covers exactly one case well. Zoho publishes the [Zoho CRM Lead Magnet](https://wordpress.org/plugins/zoho-crm-forms/) plugin on WordPress.org. It embeds Zoho CRM webforms, or maps a Contact Form 7 form, and captures the submission straight into the Leads module. If all you need is a contact form that produces leads, install it and stop reading. It watches forms and nothing else. A WooCommerce order, a membership renewal, a user registration, a custom post type going live, a support ticket closing: none of those are a form, and the plugin has no way to see them. For any event that is not a form submission, you are talking to the Zoho CRM REST API directly, and that means dealing with Zoho's OAuth model, which is where most integrations go wrong before they send a single record. / Auth ## How do you authenticate a WordPress site to Zoho CRM? With OAuth 2.0 through a **Self Client**, which is Zoho's name for the server-to-server case where you are both the developer and the account owner.¹ There is no browser redirect. In the Zoho API Console you create a Self Client, open its _Generate Code_ tab, enter the scope you need, pick a duration between 3 and 10 minutes, and Zoho displays a grant code. You then exchange that code, once, from your server: PHP — the one-time exchange, run from WP-CLI or an admin action ``` $res = wp_remote_post( 'https://accounts.zoho.com/oauth/v2/token', [ 'timeout' => 15, 'body' => [ 'grant_type' => 'authorization_code', 'client_id' => $client_id, 'client_secret' => $client_secret, 'code' => $grant_code, // valid for 3-10 minutes, single use ], ] ); $body = json_decode( wp_remote_retrieve_body( $res ), true ); // Keep all three. The refresh token is the one you cannot get again. $access_token = $body['access_token']; // expires_in: 3600 $refresh_token = $body['refresh_token']; $api_domain = $body['api_domain']; // e.g. https://www.zohoapis.eu ``` The response is the important half. It carries an `access_token` that expires in `3600` seconds, a `refresh_token` that Zoho's [token validity page](https://www.zoho.com/crm/developer/docs/api/v8/token-validity.html) says does not expire until a user revokes it, and an `api_domain`. Store the refresh token encrypted. Losing it means generating a new grant code by hand, and each Self Client is capped at 20 stored refresh tokens per user, so an integration that mints a fresh one on every deploy eventually gets its oldest revoked. From then on the site never touches the grant flow again. Every API call carries `Authorization: Zoho-oauthtoken {access_token}`, and when the hour is up you [refresh](https://www.zoho.com/crm/developer/docs/api/v8/refresh.html) with a POST to the same token endpoint using `grant_type=refresh_token`. / Refresh ## Why does refreshing the token on every request break the integration? Because Zoho caps it: you can generate at most **10 access tokens from one refresh token in 10 minutes**. The eleventh attempt is answered with _Access Denied_ and the message "You have made too many requests continuously". A worker that refreshes before every call works in testing, where nothing fires ten times in ten minutes, and fails on the first busy morning in production. Cache the access token for its lifetime and refresh only when it is about to expire or when a call comes back with `INVALID_TOKEN`. A transient with a 55-minute expiry is enough on a single server; on multi-server hosting the cache has to be shared, or each node mints its own token and the ten-per-ten-minutes cap is spent by the fleet rather than by you. PHP — token cache with refresh-on-miss ``` function zoho_access_token() { $cached = get_transient( 'zoho_crm_access_token' ); if ( $cached ) { return $cached; } // {accounts-server} is the host the grant response named. Never hard-code .com. $res = wp_remote_post( ZOHO_ACCOUNTS . '/oauth/v2/token', [ 'timeout' => 15, 'body' => [ 'grant_type' => 'refresh_token', 'refresh_token' => zoho_stored_refresh_token(), 'client_id' => ZOHO_CLIENT_ID, 'client_secret' => ZOHO_CLIENT_SECRET, ], ] ); $body = json_decode( wp_remote_retrieve_body( $res ), true ); if ( empty( $body['access_token'] ) ) { return new WP_Error( 'zoho_refresh', $body['error'] ?? 'no token' ); } // 3600s lifetime; cache for 55 minutes so a call never starts on a dead token. set_transient( 'zoho_crm_access_token', $body['access_token'], 55 * MINUTE_IN_SECONDS ); return $body['access_token']; } ``` FIG 01 — One grant, one refresh token, and an upsert that says whether it inserted or updated / Datacentre ## Which Zoho host should the site call? The one the token response names, and only that one. Zoho runs separate datacentres and a customer's account lives in exactly one of them. The accounts host and the API host differ per region:² | Datacentre | Accounts host | API host | | --- | --- | --- | | US | accounts.zoho.com | www.zohoapis.com | | EU | accounts.zoho.eu | www.zohoapis.eu | | India | accounts.zoho.in | www.zohoapis.in | | Australia | accounts.zoho.com.au | www.zohoapis.com.au | | Japan | accounts.zoho.jp | www.zohoapis.jp | | China | accounts.zoho.com.cn | www.zohoapis.com.cn | | Canada | zohocloud.ca | www.zohoapis.ca | Hard-coding `www.zohoapis.com` is the classic first-integration bug: it works for a US developer's trial account and returns `INVALID_TOKEN` for the European client the integration was built for, because the token was issued by `accounts.zoho.eu` and the US API host has never heard of it. Read `api_domain` from the token response, store it next to the refresh token, and build every URL from it. / Records ## Which endpoint creates the lead, and how do you avoid duplicates? [Upsert](https://www.zoho.com/crm/developer/docs/api/v8/upsert-records.html), not insert. `POST {api_domain}/crm/v8/Leads/upsert` takes the same `data` array as the plain [insert endpoint](https://www.zoho.com/crm/developer/docs/api/v8/insert-records.html), checks each record against `duplicate_check_fields`, and updates the match instead of creating a second one. For Leads and Contacts the default check field is `Email`, so a form that submits twice, or a queue that redelivers after a timeout, updates the lead it made the first time. The response says which happened: `"action": "insert"` or `"action": "update"`, with `duplicate_field` naming the field that matched. Field names are the module's API names, not the labels in the CRM interface: `Last_Name`, `Company`, `Email`, `Lead_Source`. `Last_Name` is mandatory on Leads, and a record without it is rejected with `MANDATORY_NOT_FOUND`. A custom field gets an API name generated at creation that may not match what you typed. PHP — upsert one lead ``` $res = wp_remote_post( $api_domain . '/crm/v8/Leads/upsert', [ 'timeout' => 10, 'headers' => [ 'Authorization' => 'Zoho-oauthtoken ' . zoho_access_token(), 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( [ 'data' => [ [ 'Last_Name' => $last_name, // mandatory on Leads 'First_Name' => $first_name, 'Email' => $email, // the default duplicate check field 'Company' => $company, 'Lead_Source' => 'Website', ] ], 'duplicate_check_fields' => [ 'Email' ], 'trigger' => [ 'workflow' ], // [] to skip CRM automations for this write ] ), ] ); $body = json_decode( wp_remote_retrieve_body( $res ), true ); $result = $body['data'][0] ?? []; if ( ( $result['code'] ?? '' ) !== 'SUCCESS' ) { // MANDATORY_NOT_FOUND / INVALID_DATA will fail identically on retry. // INVALID_TOKEN means refresh once and replay. } $action = $result['action'] ?? ''; // 'insert' or 'update' ``` The `trigger` array decides whether the CRM's own workflows, approvals and blueprints run for this write. Leaving it out runs them; an empty array skips them. That is a real decision: a lead-assignment workflow that emails a rep on every new lead is usually wanted, and a workflow that re-syncs the record back to your website is usually a loop. Per-record results come back inside a `200`. A batch of 100 in which 3 failed still answers `200`, with `MANDATORY_NOT_FOUND` or `INVALID_DATA` on the three. Check `data[].code`, not the HTTP status. > An upsert keyed on email can be delivered twice with no consequence. An insert cannot. That one choice is what makes every retry in the rest of this article safe. — what the upsert buys you / Limits ## What are the Zoho CRM API limits, and what does a lead cost? Zoho meters in **API credits** per 24 hours, set by the edition, plus a **concurrency** cap on simultaneous calls.³ Both exhaust with a `TOO_MANY_REQUESTS` error. | Edition | Credits per 24h | Concurrent calls | | --- | --- | --- | | Free | 5,000 | 5 | | Standard | 50,000 + 250 per user licence, max 100,000 | 10 | | Professional | 50,000 + 500 per user licence, max 3,000,000 | 15 | | Enterprise | 50,000 + 1,000 per user licence, max 5,000,000 | 20 | | Ultimate | 50,000 + 2,000 per user licence | 25 | Insert, update and upsert cost **1 credit for every 10 records** in a call, so a call with one record costs one credit, and a call with 100 records, the maximum, costs 10. Token refreshes are not credits. The arithmetic for a single-record integration is therefore simple: - Free: 5,000 credits ÷ 1 = **5,000 single-record upserts a day**. - Professional with 5 users: 50,000 + (5 × 500) = 52,500 credits = **52,500 a day**. - Enterprise with 20 users: 50,000 + (20 × 1,000) = **70,000 a day**. Batching changes the picture by an order of magnitude. Sending 100 records per call costs 10 credits for the lot, or 0.1 per record, so the same Free edition covers 5,000 ÷ 10 × 100 = **50,000 records a day**. A WooCommerce store syncing every order status change one at a time can approach the Free cap on a busy day; the same store flushing a batch every minute never gets near it. The concurrency cap is the one that surprises people. Five simultaneous calls on Free means that a burst of six form submissions in the same second, each handled inline, puts the sixth over the limit even though the daily budget is barely touched. That is a rate problem, not a volume problem, and the fix is the same as in the next section. ![Cyberpunk illustration of a young worker with taped forearm wiring reaching in under the lit head of a stamping press, eyes closed, working the bed by feel.](https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/og_image.jpg) / Failure ## Should the Zoho call run inside the WordPress request? No. Whatever created the record in WordPress is a request with a person waiting at the end of it, and an outbound call inside it makes that person wait for Zoho. With a 10-second timeout, a slow hour at the datacentre becomes a 10-second checkout, PHP-FPM workers pile up, and the concurrency cap above turns any traffic spike into a run of `TOO_MANY_REQUESTS`. Record the intent when the hook fires, return immediately, and let a background worker own the call. The worker is also the only place the token cache above is safe: a synchronous handler that refreshes on a miss under concurrent load is exactly how ten refreshes land inside ten minutes. Which failures deserve a retry has a precise answer. `TOO_MANY_REQUESTS`, a `5xx` and a connection timeout are all worth repeating later. `INVALID_TOKEN` deserves one refresh and one replay. `MANDATORY_NOT_FOUND`, `INVALID_DATA` and `OAUTH_SCOPE_MISMATCH` will fail identically forever, and retrying them five times only spends credits you are rationing. | Concern | Hand-rolled wp\_remote\_post | Webhook Actions | | --- | --- | --- | | Zoho connector | None — you write the token cache and the upsert | Also none. It delivers a mapped payload to a URL you name; the OAuth refresh still has to live somewhere you control | | Delivery timing | Inline — the visitor waits for Zoho | Queued — the request returns before the call runs | | TOO\_MANY\_REQUESTS / 5xx | One attempt, then the lead is gone | Exponential backoff, 5 attempts by default | | MANDATORY\_NOT\_FOUND | Retries a doomed payload if you loop naively | Marked failed at once — a bad payload is not retried | | Evidence of what was sent | Whatever you remembered to error\_log() | Per-attempt log with request, response and replay | | Refresh token and client secret | Constants in wp-config or a plugin option | Not these — the vault stores a finished header, not the exchange that mints one | The honest read of that table: the right-hand column is not a Zoho integration you can pick off a shelf. There is no connector catalogue, and the refresh-token dance is still yours to own. What it removes is the queue, the backoff curve and the attempt log, which is the part that takes a week and the part people skip. The credential row is the one people misread, so be exact about it. The plugin's **Credentials Vault** stores a finished authorization header — a bearer token, basic auth, or a raw value for a header you name — encrypted at rest, referenced from a webhook by id, and injected only at dispatch, where it is also redacted out of the delivery log. _Write-only_ means the secret goes in and only a masked hint comes back. What it does not do is run an OAuth exchange, so Zoho's refresh token and client secret stay with whatever code mints the access token. The vault is the right home for an API that authenticates with a static token instead, [Pipedrive for instance](https://wpwebhooks.org/blog/wordpress-pipedrive-integration/). try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Exposure ## What does Zoho CRM not protect you from? Three things, and none of them show up in a successful test. **The refresh token is a permanent credential.** It does not expire, it can be used from anywhere, and it grants everything the scope allowed. Scope it narrowly (`ZohoCRM.modules.leads.CREATE` and `UPDATE` rather than `ZohoCRM.modules.ALL`), store it encrypted, and revoke it from the API Console the day the integration is retired. A refresh token in a theme file that ends up in a public repository is a CRM export waiting to happen. **A public form is an unauthenticated write path into the CRM.** Anything that turns a submission into a lead has handed the internet a way to create leads, and each one costs a credit from a budget the sales team shares. Spam does not just create junk records; at volume it exhausts the daily allowance and every other integration on the account starts failing. Gate the form before the queue, not after. **The trigger array can start a loop.** A CRM workflow that pushes lead changes back to your website, combined with an upsert that fires on every change to the WordPress record, is two systems updating each other forever, one credit at a time. Decide which direction is authoritative and pass `"trigger": []` on the writes that should stay silent. If the destination is a different CRM, the shape is the same and the traps differ: [Salesforce meters calls per org on a rolling day](https://wpwebhooks.org/blog/wordpress-salesforce-integration/), [Pipedrive meters tokens per operation with a burst window](https://wpwebhooks.org/blog/wordpress-pipedrive-integration/), and [HubSpot limits requests per ten seconds](https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/). What they share is the advice above: never make the call where the visitor is waiting. /Footnotes ¹ Self Client authorisation and the token request, [Zoho CRM API v8 documentation](https://www.zoho.com/crm/developer/docs/api/v8/auth-request.html); token lifetimes and the ten-per-ten-minutes cap from the [token validity page](https://www.zoho.com/crm/developer/docs/api/v8/token-validity.html). ² Multi-datacentre hosts, [Zoho CRM API v8 documentation](https://www.zoho.com/crm/developer/docs/api/v8/multi-dc.html). ³ API credits, per-operation costs and concurrency, [Zoho CRM API limits](https://www.zoho.com/crm/developer/docs/api/v8/api-limits.html). Read 2026-09-10; editions change. ⁴ Scope strings, [Zoho CRM OAuth scopes](https://www.zoho.com/crm/developer/docs/api/v8/scopes.html). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Zoho CRM Integration: OAuth, Upsert, Limits","description":"A WordPress Zoho CRM integration on the v8 API: Self Client OAuth, the refresh-token cap, per-datacentre hosts, and the upsert that never duplicates a lead.","datePublished":"2026-09-05","dateModified":"2026-09-05","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of a young worker with taped forearm wiring reaching in under the lit head of a stamping press, eyes closed, working the bed by feel."},"keywords":["wordpress zoho crm integration","zoho crm wordpress","wordpress zoho","zoho crm api wordpress","zoho crm wordpress plugin"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Zoho CRM Integration: OAuth, Upsert, Limits","item":"https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is there an official Zoho CRM plugin for WordPress?","acceptedAnswer":{"@type":"Answer","text":"Yes. Zoho publishes the Zoho CRM Lead Magnet plugin on WordPress.org, which embeds Zoho webforms or maps a Contact Form 7 form and captures submissions into the Leads module. It handles forms only. A WooCommerce order, a user registration or a custom post type event is not a form, so those need the REST API directly."}},{"@type":"Question","name":"How does a WordPress site authenticate to the Zoho CRM API?","acceptedAnswer":{"@type":"Answer","text":"Through OAuth 2.0 with a Self Client created in the Zoho API Console. The console issues a grant code valid for 3 to 10 minutes, which the site exchanges once at the token endpoint for an access token, a refresh token and the api_domain. Every API call then sends Authorization: Zoho-oauthtoken followed by the access token."}},{"@type":"Question","name":"How long do Zoho CRM access and refresh tokens last?","acceptedAnswer":{"@type":"Answer","text":"An access token is valid for one hour. A refresh token does not expire until a user revokes it, and at most 20 refresh tokens can be stored per user per client. You can generate at most 10 access tokens from one refresh token in 10 minutes, so cache the access token rather than refreshing on every request."}},{"@type":"Question","name":"Which Zoho API domain should my integration call?","acceptedAnswer":{"@type":"Answer","text":"The api_domain returned in the token response. Zoho runs separate datacentres, and an EU account issues tokens from accounts.zoho.eu that only www.zohoapis.eu accepts. Hard-coding www.zohoapis.com works for a US trial account and returns INVALID_TOKEN for a customer in any other region."}},{"@type":"Question","name":"What do Zoho CRM API calls cost against the daily limit?","acceptedAnswer":{"@type":"Answer","text":"Zoho meters API credits per 24 hours by edition: 5,000 on Free, and 50,000 plus a per-user allowance on paid editions. Insert, update and upsert cost one credit for every 10 records in a call, so a single-record upsert costs one credit and a 100-record batch costs ten. Concurrent calls are also capped, from 5 on Free to 25 on Ultimate."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-zoho-crm-integration.png","caption":"FIG 01 — One grant, one refresh token, and an upsert that says whether it inserted or updated","description":"A self client in the Zoho API console issues a grant code that lives only a few minutes. The site exchanges it once for an access token and a refresh token, and the token response also names the accounts server and the API domain for the customer datacentre, which must be used for every later call. Access tokens expire after an hour, so the worker refreshes on a 401 and replays once. Records are written with the upsert endpoint, which checks the email field by default and answers with an action of insert or update, so a repeated delivery never creates a second lead. Every ten records cost one API credit from a daily allowance set by the CRM edition.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Salesforce Integration: API, OAuth and Limits" description: "A WordPress Salesforce integration built directly on the REST API: the client credentials flow, upserting by external ID, and the org-wide API limits." url: "https://wpwebhooks.org/blog/wordpress-salesforce-integration/" date: "2026-09-03" --- # WordPress Salesforce Integration: API, OAuth and Limits **TL;DR:** Connecting WordPress to Salesforce is three decisions — how you get a token, which endpoint you call, and who absorbs the failure when Salesforce is slow. - Use the **OAuth 2.0 client credentials flow**. It is the server-to-server flow, needs no logged-in user, and the token response tells you which host to call. - Call the token response's `instance_url`, never the login host. This is the single most common first-integration bug. - **Upsert by external ID** rather than create. Salesforce answers **201** when it inserted and **204** when it updated, so a repeated delivery is harmless. - The daily API allowance is **org-wide and shared** across REST, SOAP and Bulk. Your form competes with every other integration in the org. - Never call Salesforce inline on the request that created the record. Queue it. / Auth ## How do you authenticate a WordPress site to Salesforce? Use the [OAuth 2.0 client credentials flow](https://help.salesforce.com/s/articleView?language=en_US&id=xcloud.remoteaccess_oauth_client_credentials_flow.htm&type=5). It exists for exactly this shape of integration: a server acting on its own behalf, with no human at a browser to approve anything. You configure an External Client App (or a classic Connected App) in Salesforce Setup, enable the client credentials flow, nominate a run-as user, and you get a consumer key and secret. Two older approaches still show up in tutorials and both are worth skipping. The username-password flow embeds a password and a security token in your source and is reserved by Salesforce for special scenarios. The web server flow is designed for apps acting on behalf of a signed-in user, which is not what a form submission is. The token exchange is a single POST. What matters is the response, not the request: PHP — exchanging client credentials for a token ``` $res = wp_remote_post( 'https://MyDomain.my.salesforce.com/services/oauth2/token', [ 'timeout' => 15, 'body' => [ 'grant_type' => 'client_credentials', 'client_id' => $consumer_key, 'client_secret' => $consumer_secret, ], ] ); if ( is_wp_error( $res ) ) { return $res; // transport failure — worth retrying } $body = json_decode( wp_remote_retrieve_body( $res ), true ); // Both of these matter. The second one is the part people miss. $token = $body['access_token'] ?? ''; $host = $body['instance_url'] ?? ''; ``` The `instance_url` in that response is the host every subsequent API call must use. It is not always the domain you authenticated against, it can differ per org, and it can change after a Salesforce migration. Hard-coding your My Domain and ignoring `instance_url` produces an integration that works in your sandbox and fails in someone else's production org. Tokens from this flow are short-lived and carry no refresh token — you request a new one when the old one stops working. Treat a `401` as "fetch a fresh token and replay once", and anything still failing after that as a configuration problem rather than something to keep retrying. FIG 01 — Token, version discovery, and the upsert that decides create or update / Endpoint ## Which Salesforce endpoint should a WordPress site call? For one record at a time, the [sObject Rows by External ID](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_upsert.htm) resource, addressed with `PATCH`. The path carries the object, the external ID field, and the value: `PATCH /services/data/v64.0/sobjects/Contact/External_Id__c/{value}` Pin an API version deliberately. Salesforce ships three releases a year and each gets its own version number, and a version stays supported for years before retirement — as of Summer '25 the oldest supported version is v31.0. Rather than guessing, ask the org what it supports: a `GET` to `/services/data/` returns the list of available versions, which is the honest way to choose one and the only way to notice an org is older than you assumed. For volume, the sObject Collections resources take up to 200 records in a single call. That matters more than it sounds, because of the limit arithmetic in the next section. ![Cyberpunk illustration: a heavy armoured spar driven straight across a dark gap and locked into a glowing coupling ring on the structure opposite, while the stripped, unlit relay platform that used to sit between the two hangs by slack cables beneath it.](https://wpwebhooks.org/blog/wordpress-salesforce-integration/og_image.jpg) / Identity ## How do you avoid creating duplicate Salesforce records? Upsert on a field you control, rather than querying for a match and then deciding. An **external ID** is a custom field marked _External ID_ and _Unique_ in Salesforce — a WordPress user ID, an order number, an email address — and it turns the whole create-or-update question into one idempotent call. The response tells you what happened, and the two codes are worth handling separately: | Response | Meaning | What to do | | --- | --- | --- | | 201 Created | No record matched the external ID, so Salesforce inserted one | Store the returned record id if you need it later | | 204 No Content | A record matched and was updated | Nothing — success with an empty body is not an error | | 400 duplicate value | The external ID matched more than one record | Fix the field uniqueness in Salesforce; the payload is not the problem | | 401 Unauthorized | Token expired or invalid | Fetch a fresh token, replay once, then stop | | 403 REQUEST\_LIMIT\_EXCEEDED | The org burned its daily API allowance | Back off hard — this is not your integration alone | The `204` catches people out. A successful update returns an **empty body**, so code that parses JSON unconditionally throws on the happy path and the delivery gets logged as a failure while Salesforce did exactly what was asked. Check the status before you decode. The duplicate-value error is the other one worth naming. Salesforce documents a _"Duplicate value found: duplicates value on record with id"_ failure on PATCH upserts, and it appears most often when two deliveries for the same record land within milliseconds of each other. Serialising deliveries per record — which a queue does for free — removes it. > An upsert keyed on a field you own can be delivered twice with no consequence. A create cannot. Everything else about retry design follows from that one choice. — the rule that makes retries safe / Limits ## What are the Salesforce API limits, and who else spends them? Salesforce meters API calls per org on a **24-hour rolling basis**, not per integration and not per calendar day. Enterprise Edition allows 100,000 calls plus 1,000 per licensed user; Unlimited allows 100,000 plus 5,000 per user. The pool is shared across REST, SOAP, Bulk and Connect — so your WordPress integration is drawing from the same budget as the org's data warehouse sync, its marketing tooling and every admin running a report through an API client. Work out what your site actually spends before shipping. A form doing one upsert per submission at 200 submissions a day costs 200 calls — trivial. The same form doing a lookup, then an upsert, then a second call to attach a related record costs three times that, and a WooCommerce store firing on four order-status transitions each with a lookup is a different conversation entirely: - 200 submissions × 1 upsert = **200 calls/day** - 200 submissions × 3 calls (lookup + upsert + related record) = **600 calls/day** - 2,000 orders × 4 status changes × 3 calls = **24,000 calls/day** — a quarter of the base allowance for one plugin That last line is why sObject Collections matters. Batching those 24,000 calls at up to 200 records each brings the same work down to roughly 120 calls, and the difference between those two numbers is the difference between an integration nobody notices and one the Salesforce admin asks you to turn off. Salesforce treats the daily cap as a soft limit briefly and then enforces it with `REQUEST_LIMIT_EXCEEDED`. Concurrency is metered separately: production orgs allow 25 simultaneous long-running requests, where "long-running" means 20 seconds or more. / Failure ## Should the Salesforce call run inside the WordPress request? No. Whatever created the record in WordPress — a form submission, a checkout, a user registration — is a request with a person waiting at the end of it, and an outbound API call inside it makes that person wait for Salesforce. The reasoning is not about typical latency, it is about the tail. Salesforce is normally fast. But when it is slow, or the token endpoint is slow, or DNS hiccups, your checkout inherits that latency directly. With a 15-second timeout, a Salesforce incident becomes a 15-second checkout for every customer, and PHP-FPM workers pile up behind it. The correct shape is to record the intent, return immediately, and let a background worker own the delivery — that way a Salesforce outage delays data rather than breaking the site. Which failures deserve a retry is a question with a precise answer, and getting it wrong is expensive in both directions. A `5xx`, a `429` and a connection timeout are all worth repeating. A `400` caused by a bad field name will fail identically forever — retrying it five times just multiplies the API calls you are already rationing. | Concern | Hand-rolled wp\_remote\_post | Webhook Actions | | --- | --- | --- | | Connector catalogue | None — you write every call | Also none. You point it at an endpoint you name; there is no Salesforce connector to pick from a list | | Delivery timing | Inline — the visitor waits for Salesforce | Queued — the request returns before the API call runs | | Retry on 5xx / 429 | One attempt, then the data is gone | Exponential backoff, 5 attempts by default | | Retry on 4xx | Retries a doomed payload if you loop naively | Marked failed immediately — a bad payload is not retried | | Evidence of what was sent | Whatever you remembered to error\_log() | Per-attempt log with request, response and replay | | Consumer key and secret | Constants in wp-config or the database | Not these — the vault stores a finished header, not the exchange that mints one | The honest read of that table: the right-hand column is not a Salesforce integration you can pick off a shelf. There is no connector catalogue — you still decide the endpoint, the payload and the field mapping. What it removes is the queue, the backoff curve and the attempt log, which is the part that takes a week and is the part people skip. The credential row deserves precision, because it is the one people misread. The plugin's **Credentials Vault** stores a finished authorization header — a bearer token, basic auth, or a raw value for a header you name — encrypted at rest, referenced from a webhook by id, injected only at dispatch and redacted out of the delivery log. _Write-only_ means the secret goes in and only a masked hint comes back. What it does not do is perform the client credentials exchange, so the consumer key and secret stay with whatever mints the token. The vault is the right home for an API that authenticates with a static token instead, [Pipedrive for instance](https://wpwebhooks.org/blog/wordpress-pipedrive-integration/). try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Exposure ## What does Salesforce not protect you from? Three things, and none of them show up in a successful test. **The allowance is org-wide, so your bug is everyone's outage.** A retry loop with no cap, or a hook that fires on every post save instead of on publish, does not break your integration first. It exhausts the shared daily quota and breaks the org's other integrations, silently, hours later. Cap attempts and know your per-day arithmetic before you ship. **A public form is an unauthenticated write path into your CRM.** Anything that turns a form submission into a Salesforce record has handed the internet a way to create records. Without rate limiting and spam filtering in front of it, the cost is not just junk data — it is junk data that consumes the API allowance above. Gate it before the queue, not after. **The run-as user's permissions are the integration's permissions.** The client credentials flow acts as a nominated user, and it inherits that user's object and field access in full. Pointing it at an administrator is the fast way to get a working integration and the fast way to give a web-facing code path write access to every object in the org. Create a dedicated integration user with access to the objects you actually touch. For the specific case of sending form entries rather than platform events, the mechanics differ enough to be worth their own walkthrough — see [sending Gravity Forms entries to Salesforce](https://wpwebhooks.org/blog/gravity-forms-salesforce-integration/), which covers the licence question and the Lead object. If the destination is HubSpot rather than Salesforce, [the HubSpot contacts API](https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/) has a different rate-limit model worth knowing before you choose. /Footnotes ¹ OAuth 2.0 client credentials flow, [Salesforce Help](https://help.salesforce.com/s/articleView?language=en_US&id=xcloud.remoteaccess_oauth_client_credentials_flow.htm&type=5). ² sObject Rows by External ID, [REST API Developer Guide](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_upsert.htm). ³ Authorization through connected apps and OAuth 2.0, [REST API Developer Guide](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/intro_oauth_and_connected_apps.htm). ⁴ API limits and monitoring your API usage, [Salesforce Developers Blog](https://developer.salesforce.com/blogs/2024/11/api-limits-and-monitoring-your-api-usage). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Salesforce Integration: API, OAuth and Limits","description":"A WordPress Salesforce integration built directly on the REST API: the client credentials flow, upserting by external ID, and the org-wide API limits.","datePublished":"2026-09-03","dateModified":"2026-09-03","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-salesforce-integration/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-salesforce-integration/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration: a heavy armoured spar driven straight across a dark gap and locked into a glowing coupling ring on the structure opposite, while the stripped, unlit relay platform that used to sit between the two hangs by slack cables beneath it."},"keywords":["wordpress salesforce integration","salesforce integration wordpress","wordpress to salesforce","salesforce rest api wordpress","connect wordpress to salesforce"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Salesforce Integration: API, OAuth and Limits","item":"https://wpwebhooks.org/blog/wordpress-salesforce-integration/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do I connect WordPress to Salesforce without a plugin marketplace connector?","acceptedAnswer":{"@type":"Answer","text":"Use the Salesforce REST API directly with the OAuth 2.0 client credentials flow. Configure an External Client App in Salesforce Setup, enable the client credentials flow, and exchange the consumer key and secret for an access token. The token response also returns the instance_url, which is the host every subsequent API call must use."}},{"@type":"Question","name":"Which OAuth flow should a server-to-server Salesforce integration use?","acceptedAnswer":{"@type":"Answer","text":"The client credentials flow. It is designed for a server acting on its own behalf with no user at a browser, which is what a form submission or an order webhook is. The username-password flow embeds a password in your source and the web server flow assumes a signed-in user, so neither fits."}},{"@type":"Question","name":"What is the difference between a 201 and a 204 from a Salesforce upsert?","acceptedAnswer":{"@type":"Answer","text":"A 201 Created means no record matched the external ID so Salesforce inserted a new one. A 204 No Content means a record matched and was updated. The 204 response has an empty body, so code that decodes JSON unconditionally will throw on the path where the update succeeded."}},{"@type":"Question","name":"What are the Salesforce API daily limits?","acceptedAnswer":{"@type":"Answer","text":"Enterprise Edition allows 100,000 calls per 24 hours plus 1,000 per licensed user; Unlimited Edition allows 100,000 plus 5,000 per user. The allowance is measured on a rolling 24-hour basis and is shared org-wide across REST, SOAP, Bulk and Connect, so your integration competes with every other one in the org."}},{"@type":"Question","name":"Should the Salesforce API call run inside the WordPress request?","acceptedAnswer":{"@type":"Answer","text":"No. An outbound API call inside a checkout or form submission makes the visitor wait for Salesforce, so a Salesforce slowdown becomes a slow checkout and PHP-FPM workers pile up behind it. Queue the delivery and let a background worker own it, so an outage delays data rather than breaking the site."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-salesforce-integration.png","caption":"FIG 01 — Token, version discovery, and the upsert that decides create or update","description":"A server-to-server Salesforce integration starts by exchanging client credentials for a short-lived access token at the org token endpoint. The response carries both the token and the instance URL, and the instance URL is the host every later call must use rather than the login host. The site then sends a PATCH to the sObject rows by external ID resource, addressing the record by a field you control rather than by a Salesforce record id. Salesforce answers 201 Created when it inserted a new record and 204 No Content when it updated an existing one, so the same call is safe to repeat. Every one of these calls is drawn from a single org-wide daily allowance shared by REST, SOAP and Bulk traffic, so an unthrottled form can exhaust the quota for every other integration in the org.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Gravity Forms to HubSpot: Send Entries to the CRM API" description: "Gravity Forms HubSpot integration in code: the gform_after_submission signature, private app tokens, contact properties, and the real rate limits." url: "https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/" date: "2026-09-01" --- # Gravity Forms to HubSpot: Send Entries to the CRM API **TL;DR:** Gravity Forms gives you the entry; HubSpot wants properties. Almost every problem in this integration lives in the gap between those two vocabularies. - `gform_after_submission` fires with `$entry` and `$form`, **after** the entry is saved and notifications are sent. - HubSpot wants **internal property names** (`firstname`), not the labels you see in the CRM (_First Name_). Entry values are keyed by **field ID** (`'1.3'`), not by label either. - Authenticate with a **private app token**: `Authorization: Bearer …`. - Rate limits are per app and per account: **100 requests / 10 seconds** on Free and Starter, 190 on Professional and Enterprise. - Use `batch/upsert` keyed on email if the same person can submit twice. / Hook ## Which Gravity Forms hook should send the entry? [`gform_after_submission`](https://docs.gravityforms.com/gform_after_submission/), which runs once the entry has been created and notifications have gone out. Its signature is two arguments in a fixed order: PHP — the hook signature, and the form-specific variant ``` // All forms. Note the argument count — omit the 2 and $form arrives as null. add_action( 'gform_after_submission', 'queue_entry_for_hubspot', 10, 2 ); // One form only, by form ID. Cheaper than an if() on every submission. add_action( 'gform_after_submission_5', 'queue_entry_for_hubspot', 10, 2 ); function queue_entry_for_hubspot( $entry, $form ) { // $entry values are keyed by FIELD ID, not by label. // A name field's parts are decimals: 1.3 first, 1.6 last. $payload = [ 'email' => rgar( $entry, '3' ), 'firstname' => rgar( $entry, '1.3' ), 'lastname' => rgar( $entry, '1.6' ), ]; // Hand off and return. Do not call HubSpot from here. do_action( 'my_queue_crm_delivery', $payload, $form['id'] ); } ``` Two details in that snippet cause most of the support threads. The **argument count** is one: `add_action` defaults to passing a single argument, so a callback declared with two parameters but registered without the trailing `2` receives `null` for `$form`. The other is that entry values are addressed by **field ID**. A Name field is a compound field whose parts are decimal keys — `1.3` for first, `1.6` for last — and they are stable identifiers, not labels, which means renaming a field label in the form editor does not break your mapping but reordering fields in a rebuilt form absolutely does. Use `rgar()` rather than direct array access. Unfilled optional fields are simply absent from the entry array, and `$entry['7']` on an empty field emits a notice on every submission. FIG 02 — Why the CRM call belongs after the submission, not inside it / Auth ## How do you authenticate against the HubSpot API? With a [private app access token](https://developers.hubspot.com/docs/apps/legacy-apps/private-apps/overview), sent as a bearer token. You create the app inside your HubSpot account, grant it scopes, and it issues a token: `Authorization: Bearer ` Creating a contact needs the `crm.objects.contacts.write` scope; reading one back needs `crm.objects.contacts.read`. Grant only what the integration uses — a token scoped to contacts cannot be turned into a deals-and-tickets token by an attacker who finds it in a database backup. Treat the token as a credential, not configuration. It should not be in `wp-config.php` in a repository, not in a theme file, and not in a plugin setting that renders it back into an admin page in plain text. Encrypted-at-rest storage that returns a masked hint rather than the secret is the shape you want. / Payload ## What does the HubSpot contacts endpoint expect? A `POST` to `/crm/v3/objects/contacts` with a single `properties` object. There is no envelope and no object-type field — the path carries the object type: PHP — creating the contact from a queued worker ``` $res = wp_remote_post( 'https://api.hubapi.com/crm/v3/objects/contacts', [ 'timeout' => 15, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( [ 'properties' => [ // INTERNAL names, lowercase — not the CRM's display labels. 'email' => $payload['email'], 'firstname' => $payload['firstname'], 'lastname' => $payload['lastname'], ], ] ), ] ); $code = wp_remote_retrieve_response_code( $res ); if ( 429 === $code ) { // Rate limited. Back off — this one IS worth retrying. return new WP_Error( 'hubspot_rate_limited' ); } if ( $code >= 400 && $code < 500 ) { // Unknown property, bad email. Retrying changes nothing. return new WP_Error( 'hubspot_bad_payload' ); } ``` The property names are the trap. HubSpot's API takes **internal names**, which are lowercase and often unpunctuated — `firstname`, not `First Name` and not `first_name`. Custom properties get an internal name generated when you create them, and it does not always match what you typed. Read the real name from the property settings in HubSpot rather than inferring it from the label, because an unknown property name returns a `400` that will fail identically on every retry. > A 429 means "the same request, later." A 400 means "this request, never." Code that treats them the same either loses good data or hammers the API with a payload that cannot succeed. — the distinction that decides your retry policy / Limits ## What are the HubSpot API rate limits? Two limits apply at once: a burst limit measured per app over ten seconds, and a daily limit measured per account across every app on it.¹ | Subscription | Per 10 seconds (per app) | Per day (per account) | | --- | --- | --- | | Free / Starter | 100 | 250,000 | | Professional | 190 | 625,000 | | Enterprise | 190 | 1,000,000 | | With API Limit Increase | 250 | +1,000,000 per increase | The per-account daily figure is the one to think about when several integrations share a portal — your form is not spending its own budget, it is spending the account's. The burst limit is the one a form actually hits, and it hits it in a specific way: not from steady traffic, but from a campaign, an import, or a retry storm after an outage, when a hundred queued deliveries all become due in the same second. HubSpot answers a breach with `429` and an `errorType` of `RATE_LIMIT`, and every response carries the budget in headers you can read rather than guess at: - `X-HubSpot-RateLimit-Max` — requests allowed in the current window - `X-HubSpot-RateLimit-Remaining` — how many are left in it - `X-HubSpot-RateLimit-Daily-Remaining` — what is left of the account's day Do the arithmetic for your own site. At the Free and Starter ceiling of 100 requests per 10 seconds, one contact call per submission supports 10 submissions per second sustained, which no ordinary form approaches. But a queue that drains 200 backed-up deliveries with no pacing sends all 200 as fast as the worker loops — roughly 20× the burst allowance — and the tail of that batch is rejected. A worker that spaces deliveries, or batches them, never sees a 429. ![Cyberpunk illustration: a chromed robotic hand seating a cable ferrule into one port of a dense termination block. The port under the hand is dark and cracked with light where the ferrule will not seat, while an identical port one row above glows open and accepting.](https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/og_image.jpg) / Duplicates ## How do you stop the same person becoming two contacts? Upsert on email instead of creating. HubSpot exposes `POST /crm/v3/objects/contacts/batch/upsert`, which takes an `inputs` array and matches on an identifier you nominate, and `POST /crm/v3/objects/contacts/batch/create` for straight inserts.² Batching pays twice here. Sending 100 contacts as a single batch call costs one request against the burst limit instead of 100, and the upsert semantics mean a repeated delivery updates the existing contact rather than creating a second one. Batch endpoints report per-item outcomes as a multi-status response, so partial failure is visible — check the individual results rather than assuming a 2xx means every record landed. / Licence ## Do you need the Gravity Forms Webhooks add-on for this? Only if you want Gravity Forms itself to make the request. The [Webhooks add-on is bundled with the Elite licence](https://www.gravityforms.com/pricing/) at $259/year — Basic ($59) and Pro ($159) do not include it, which surprises people who reasonably assume webhooks are a core feature of a forms plugin in 2026. The `gform_after_submission` hook, by contrast, is part of Gravity Forms itself and is available on every licence including Basic. That is the gap worth knowing about: the hook that lets you send an entry anywhere costs nothing extra, and what the Elite add-on sells is the interface for configuring it without code — a feed UI, field mapping and conditional logic — rather than the capability. | Concern | Inline wp\_remote\_post in the hook | Webhook Actions | | --- | --- | --- | | HubSpot connector | None — you write the call | Also none. You point it at api.hubapi.com yourself; there is no HubSpot connector to select | | Submission speed | Visitor waits for HubSpot | Entry is queued; the confirmation renders immediately | | Behaviour on 429 | Contact is lost unless you wrote the retry | Backoff and retry, up to 5 attempts by default | | Behaviour on 400 | Often retried pointlessly, or lost silently | Marked permanently failed — no wasted requests | | Proof it was sent | error\_log(), if you remembered | Per-attempt log with request, response and replay | | Token storage | Plain text in options or wp-config | Encrypted vault, never returned over the API | If the destination is Salesforce rather than HubSpot, the auth model and the limit arithmetic are different enough to change the design — [the Salesforce integration guide](https://wpwebhooks.org/blog/wordpress-salesforce-integration/) covers the token flow and the org-wide daily allowance. For the general problem of deliveries that vanish without an error, [why WordPress webhooks fail silently in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) is the companion piece. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) /Footnotes ¹ API usage guidelines and limits, [HubSpot developer documentation](https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines). ² Contacts API endpoints and batch operations, [HubSpot CRM API reference](https://developers.hubspot.com/docs/api-reference/legacy/crm/objects/contacts/guide). ³ gform\_after\_submission signature and parameters, [Gravity Forms documentation](https://docs.gravityforms.com/gform_after_submission/). ⁴ Licence tiers and add-on availability, [Gravity Forms pricing](https://www.gravityforms.com/pricing/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Gravity Forms to HubSpot: Send Entries to the CRM API","description":"Gravity Forms HubSpot integration in code: the gform_after_submission signature, private app tokens, contact properties, and the real rate limits.","datePublished":"2026-09-01","dateModified":"2026-09-01","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration: a chromed robotic hand seating a cable ferrule into one port of a dense termination block. The port under the hand is dark and cracked with light where the ferrule will not seat, while an identical port one row above glows open and accepting."},"keywords":["gravity forms hubspot","gravity forms to hubspot","gravity forms hubspot integration","gravity forms hubspot contact","hubspot crm api wordpress"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Gravity Forms to HubSpot: Send Entries to the CRM API","item":"https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Which Gravity Forms hook sends an entry to HubSpot?","acceptedAnswer":{"@type":"Answer","text":"gform_after_submission, registered with two arguments: add_action( \"gform_after_submission\", \"callback\", 10, 2 ). It receives the Entry Object and the Form Object and runs after the entry has been saved and notifications sent. A form-specific variant, gform_after_submission_5, targets a single form by ID."}},{"@type":"Question","name":"How do I authenticate a WordPress site against the HubSpot API?","acceptedAnswer":{"@type":"Answer","text":"With a private app access token sent as a bearer token: Authorization: Bearer YOUR_TOKEN. Create the private app inside your HubSpot account and grant it the crm.objects.contacts.write scope to create contacts. Store the token encrypted, never in a theme file or a plain-text plugin setting."}},{"@type":"Question","name":"What are the HubSpot API rate limits?","acceptedAnswer":{"@type":"Answer","text":"Private apps on Free and Starter are limited to 100 requests per 10 seconds per app and 250,000 requests per day per account. Professional and Enterprise allow 190 requests per 10 seconds, with 625,000 and 1,000,000 per day respectively. The daily limit is shared across every app on the account."}},{"@type":"Question","name":"Why does my HubSpot contact create return a 400 error?","acceptedAnswer":{"@type":"Answer","text":"Most often because the property name is wrong. HubSpot expects internal property names such as firstname, not the display labels shown in the CRM interface, and custom properties get an internal name generated at creation that may not match what you typed. A 400 from an unknown property will fail identically on every retry."}},{"@type":"Question","name":"How do I avoid creating duplicate HubSpot contacts?","acceptedAnswer":{"@type":"Answer","text":"Use POST /crm/v3/objects/contacts/batch/upsert keyed on email rather than the plain create endpoint. Batching also costs one request against the burst limit instead of one per contact. Batch endpoints return per-item results as a multi-status response, so check individual outcomes rather than assuming a 2xx means everything landed."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/gravity-forms-hubspot.png","caption":"FIG 02 — Why the CRM call belongs after the submission, not inside it","description":"When gform_after_submission runs, the entry is already saved and the visitor is still waiting for the page. Calling the HubSpot API inline on that request means the visitor waits for HubSpot, and a slow or rate-limited CRM becomes a slow form. The safer shape hands the entry to a queue and returns immediately, so the confirmation renders regardless of what HubSpot does. The queued worker maps entry field ids to HubSpot internal property names, which are not the labels shown in the CRM interface, then posts to the contacts endpoint with a private app bearer token. A 429 response carries rate limit headers telling the worker how much budget is left, and is worth retrying; a 400 caused by an unknown property name is not, because the same payload will fail forever.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Gravity Forms to Salesforce Without an Elite Licence" description: "Gravity Forms Salesforce integration explained: what the official Elite add-on does, and how gform_after_submission sends leads on any licence." url: "https://wpwebhooks.org/blog/gravity-forms-salesforce-integration/" date: "2026-08-30" --- # Gravity Forms to Salesforce Without an Elite Licence **TL;DR:** Gravity Forms has an official Salesforce add-on. It is good, and it is Elite-only — which is the whole reason this question gets asked. - The **Salesforce Add-On** ships with the **Elite** licence ($259/year). Basic ($59) and Pro ($159) do not include it. - The **Webhooks Add-On** is _also_ Elite-only, so "just use a webhook feed" is not the cheaper answer people assume. - `gform_after_submission` is part of Gravity Forms core and works on **every** licence, Basic included. - Send a **Lead** unless you have a reason not to — it is the object Salesforce designed for inbound strangers. - Whatever route you take, the entry must reach Salesforce _after_ the visitor gets their confirmation, not during it. / Licence ## Does Gravity Forms have an official Salesforce integration? Yes, and it is worth taking seriously before reaching for code. Gravity Forms released a [Salesforce Add-On](https://www.gravityforms.com/integrations/salesforce/) in April 2026. It creates and updates leads, contacts, accounts and other Salesforce objects from a form feed, supports conditional logic so entries only sync when criteria are met, and — the part that matters most in practice — it detects duplicates and updates existing records instead of creating new ones. That is a genuinely well-scoped integration, and if you hold an Elite licence there is no argument for writing this yourself. Configuring a feed takes minutes; the equivalent code takes a week and then needs maintaining. The catch is the licence tier. Both add-ons people reach for here sit at the top of the range: | Licence | Price / year | Salesforce Add-On | Webhooks Add-On | gform\_after\_submission | | --- | --- | --- | --- | --- | | Basic | $59 | No | No | Yes | | Pro | $159 | No | No | Yes | | Elite | $259 | Yes | Yes | Yes | The last column is the one that changes the decision. `gform_after_submission` is not an add-on — it is a hook inside Gravity Forms itself, available on every licence including the $59 Basic tier. So the real choice is not "pay for Elite or go without", it is "pay $200 more per year for a configuration UI, or spend an afternoon on the hook". Worth being honest about which way that decision usually goes. If you run one site and value your afternoons, Elite is the better buy. If you run twenty client sites on Basic licences, or you already have delivery infrastructure, paying twenty times for a feed UI you would use once per site is harder to justify. FIG 03 — The three routes from a form entry to a Salesforce lead / Object ## Should a form entry become a Lead or a Contact? A **Lead**, in almost every case. Salesforce's object model draws a deliberate line: a Lead is an unqualified person who has expressed interest and has not yet been vetted, while a Contact is a person attached to an Account you already do business with. A website form produces the former by definition — you have a name and an email address from someone you have never spoken to. Writing form submissions straight into Contacts skips the qualification step the CRM exists to manage, and it tends to annoy the people who own the Salesforce org rather more than it helps them. Leads can be converted into a Contact, an Account and an Opportunity in one operation once someone qualifies them, and that conversion is a workflow the sales team already has. The Lead object requires `LastName` and `Company`. That second one bites on consumer-facing forms that never ask for a company — the create fails with a validation error until you either add the field to the form or supply a documented placeholder. Decide which before you ship, because discovering it through failed deliveries is a poor use of a Friday. ![Cyberpunk illustration: an augmented courier crouched at an open service hatch in the plinth of a huge sealed toll arch, reaching into a plain lit maintenance corridor that runs on past the shutter the arch will not lift.](https://wpwebhooks.org/blog/gravity-forms-salesforce-integration/og_image.jpg) / Auth ## How does the entry authenticate against Salesforce? Through the OAuth 2.0 client credentials flow — the server-to-server flow, where your site exchanges a consumer key and secret for a short-lived access token, and the token response also tells you the `instance_url` every later call must use. The full setup, including the External Client App configuration and the version-discovery endpoint, is covered in [the WordPress to Salesforce integration guide](https://wpwebhooks.org/blog/wordpress-salesforce-integration/); the short version is that there is no per-user login involved and no refresh token to manage. What is specific to forms is **idempotency**. A form can be submitted twice by an impatient visitor, and a queued delivery can be retried after a timeout that actually succeeded. Both produce a duplicate Lead unless the write is an upsert keyed on something you control: PHP — upserting a Lead keyed on an external ID ``` // Entry ID is a natural external key: unique, stable, already yours. $external_id = 'gf-' . $entry['id']; $url = trailingslashit( $instance_url ) . 'services/data/v64.0/sobjects/Lead/GF_Entry_Id__c/' . rawurlencode( $external_id ); $res = wp_remote_request( $url, [ 'method' => 'PATCH', 'timeout' => 15, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( [ 'LastName' => rgar( $entry, '1.6' ), 'FirstName' => rgar( $entry, '1.3' ), 'Email' => rgar( $entry, '3' ), // Required on Lead. Supply a placeholder if the form has no company field. 'Company' => rgar( $entry, '5' ) ?: '[not provided]', 'LeadSource' => 'Web', ] ), ] ); $code = wp_remote_retrieve_response_code( $res ); // 201 = Lead created. 204 = existing Lead updated, EMPTY BODY. if ( 204 === $code ) { return true; // do not json_decode this — there is nothing to decode } ``` The `GF_Entry_Id__c` field is a custom field on Lead, marked _External ID_ and _Unique_ in Salesforce Setup. Creating it is a two-minute job and it converts every retry in the system from a risk into a no-op. Note the `204` handling: a successful update returns an empty body, so any code that decodes JSON unconditionally will throw on the path where everything worked. > The visitor's confirmation page must not depend on Salesforce being awake. Once you accept that, every other decision — queue, retry, external ID — follows. — the design rule for form-to-CRM delivery / Timing ## Why should the Salesforce call not run during the submission? Because `gform_after_submission` runs inside the request the visitor is still waiting on. The entry is already saved by that point, so the data is safe — but the confirmation page does not render until your callback returns, and a callback that calls Salesforce inherits Salesforce's latency and its outages. With a 15-second timeout on the token call and another on the upsert, a bad afternoon at Salesforce turns into a 30-second wait on a contact form, and PHP-FPM workers stack up behind every submission. Visitors who assume the form is broken submit again, which doubles the load at exactly the wrong moment — and produces the duplicate Leads the external ID above is there to absorb. Queue it. Record what needs sending, return immediately, and let a background worker own the delivery. A Salesforce outage should delay data by minutes, not break the form. The retry policy follows the same distinction as any API integration, and the failure modes are worth naming precisely: - `5xx`, `429`, connection timeout → **retry with backoff**. The request was fine; the moment was not. - `400` validation error (missing `Company`, bad picklist value) → **do not retry**. It will fail identically forever. - `401` → fetch a fresh token, replay **once**, then stop. - `403 REQUEST_LIMIT_EXCEEDED` → the org's shared daily allowance is gone. Back off hard; this is not your integration's budget alone. | Concern | Official Salesforce Add-On (Elite) | Webhook Actions | | --- | --- | --- | | Field mapping UI | Purpose-built feed with a picker for Salesforce objects and fields — clearly better here | Generic field mapping; you supply the endpoint and the JSON shape yourself | | Licence required | Gravity Forms Elite, $259/year | Works on any Gravity Forms licence, Basic included | | Scope | Gravity Forms entries only | Any WordPress or WooCommerce hook, not just forms | | Retry on 5xx / 429 | Handled by the add-on | Exponential backoff, 5 attempts by default | | Per-attempt delivery log | Feed processing is logged by Gravity Forms | Request, response and status per attempt, with replay | | Credential storage | Managed by the add-on | Encrypted vault for static secrets; the OAuth exchange that mints the token stays yours | Read that table honestly and the split is clear. On a single Elite site, the official add-on wins on the thing you will touch most — mapping fields without writing JSON. The webhook route wins on licence cost across many sites, and on scope, because the same delivery layer also carries WooCommerce orders, user registrations and anything else that fires a hook. Neither removes the need to understand the Lead object or the API limits. If your destination is HubSpot instead, [the Gravity Forms to HubSpot walkthrough](https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/) covers the equivalent path with a very different rate-limit model. And if entries are reaching neither CRM with no visible error, [why webhooks fail silently in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) is the place to start. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) /Footnotes ¹ Salesforce Add-On features and licence requirement, [Gravity Forms](https://www.gravityforms.com/integrations/salesforce/). ² Licence tiers and add-on availability, [Gravity Forms pricing](https://www.gravityforms.com/pricing/). ³ gform\_after\_submission signature, [Gravity Forms documentation](https://docs.gravityforms.com/gform_after_submission/). ⁴ sObject Rows by External ID upsert semantics, [Salesforce REST API Developer Guide](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_upsert.htm). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Gravity Forms to Salesforce Without an Elite Licence","description":"Gravity Forms Salesforce integration explained: what the official Elite add-on does, and how gform_after_submission sends leads on any licence.","datePublished":"2026-08-30","dateModified":"2026-08-30","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/gravity-forms-salesforce-integration/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/gravity-forms-salesforce-integration/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration: an augmented courier crouched at an open service hatch in the plinth of a huge sealed toll arch, reaching into a plain lit maintenance corridor that runs on past the shutter the arch will not lift."},"keywords":["gravity forms salesforce","gravity forms salesforce integration","gravity forms to salesforce","gravity forms salesforce lead","gravity forms crm integration"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Gravity Forms to Salesforce Without an Elite Licence","item":"https://wpwebhooks.org/blog/gravity-forms-salesforce-integration/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does Gravity Forms have an official Salesforce add-on?","acceptedAnswer":{"@type":"Answer","text":"Yes. Gravity Forms released a Salesforce Add-On in April 2026 that creates and updates leads, contacts, accounts and other objects from a form feed, with conditional logic and duplicate detection. It is bundled with the Elite licence at $259 per year and is not included in the Basic or Pro tiers."}},{"@type":"Question","name":"Can I send Gravity Forms entries to Salesforce without the Elite licence?","acceptedAnswer":{"@type":"Answer","text":"Yes. The gform_after_submission hook is part of Gravity Forms itself rather than an add-on, so it is available on every licence including Basic. You supply the Salesforce authentication, the field mapping and the delivery, either in code or through a plugin that already owns the queue and retry logic."}},{"@type":"Question","name":"Should a Gravity Forms entry create a Lead or a Contact in Salesforce?","acceptedAnswer":{"@type":"Answer","text":"A Lead in almost every case. Salesforce treats a Lead as an unqualified person who has expressed interest and a Contact as someone attached to an existing Account. A website form produces the former by definition. Leads can be converted to a Contact, Account and Opportunity once qualified."}},{"@type":"Question","name":"How do I stop duplicate leads when a form is submitted twice?","acceptedAnswer":{"@type":"Answer","text":"Upsert on an external ID rather than creating. Add a custom field on Lead marked External ID and Unique, populate it with the Gravity Forms entry ID, and PATCH to the sObject Rows by External ID resource. Salesforce returns 201 when it inserted and 204 when it updated, so a repeated delivery is harmless."}},{"@type":"Question","name":"Why should the Salesforce call not run inside gform_after_submission?","acceptedAnswer":{"@type":"Answer","text":"Because that hook runs inside the request the visitor is still waiting on. Calling Salesforce inline makes the confirmation page wait for the API, so a slow or unavailable Salesforce becomes a slow or broken form. Queue the delivery and let a background worker send it."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/gravity-forms-salesforce.png","caption":"FIG 03 — The three routes from a form entry to a Salesforce lead","description":"A Gravity Forms entry can reach Salesforce three ways, and the licence tier decides which are open. The official Salesforce add-on is the least work and is bundled only with the Elite licence, so it is unavailable on Basic and Pro. The Webhooks add-on posts the entry to any endpoint but is also an Elite add-on, which surprises people who assume webhooks are a core feature. On any licence, the gform_after_submission hook is available for free, because it is part of Gravity Forms itself rather than an add-on. That route needs an OAuth token, field mapping and a retry story built by hand, or a plugin that already owns the queue and the delivery log.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Uncanny Automator Alternative: When to Use a Webhook" description: "An honest Uncanny Automator alternative guide: what app credits actually meter, where recipes win, and when a plain outgoing webhook is the cheaper path." url: "https://wpwebhooks.org/blog/uncanny-automator-alternative/" date: "2026-08-28" --- # Uncanny Automator Alternative: When to Use a Webhook **TL;DR:** Uncanny Automator is a recipe builder. Its free tier meters one thing and one thing only — steps that hand data to an external app. - Plugin-to-plugin steps inside WordPress are unmetered. That is most of what the tool is for, and it is genuinely good at it. - Each step that passes data to an authorised third-party app spends **1 app credit**. A free account gets **250**, granted once — not monthly. - Paid plans remove the meter entirely, starting at **$300/year** for one site on the current AI plans, or **$199/year** on the legacy automation-only tier. - An outgoing webhook is not an app integration, so nothing meters it. That is the whole reason this comparison exists. / Overview ## What does Uncanny Automator actually do? Uncanny Automator is a trigger-and-action builder for WordPress, listed on [WordPress.org](https://wordpress.org/plugins/uncanny-automator/) with 40,000+ active installations. You pick a trigger — an order completes, a form is submitted, a course is finished — and attach one or more actions. The pairing is called a recipe, and you build it in the admin without writing code. Most of what it connects is other WordPress plugins. WooCommerce to LearnDash, Gravity Forms to a membership plugin, BuddyBoss to an email list. That is the core of the product and it works well. The plugin's own listing describes the free tier as offering the "Uncanny Recipe Builder with unlimited plugin-to-plugin automations" and "hundreds of free triggers and actions across your favorite WordPress plugins". Then there is the other half: the external apps. Google Sheets, Zoom, Slack, Facebook Pages, Mailchimp. Those behave differently, and the difference is the thing worth understanding before you build anything on top of it. / Metering ## What does an app credit actually meter? One credit is spent each time a recipe passes data out to a third-party service. Automator's own documentation is unusually clear about it: "Each time a recipe passes data to another system, like creating a new row in a Google Sheet or registering someone in a Zoom webinar, it uses 1 app credit."¹ What is _not_ metered matters just as much. Credits apply to app integrations — the ones you authorise under **Automator → App integrations**, where you connect an account with the provider. Steps that stay inside WordPress cost nothing. So a recipe that enrols a student, adds a user to a group and sends a WordPress email is free to run forever; add one Google Sheets row to the end of it and every single run now costs a credit. A free account is granted **250** of them. The wording is "All registered free users get 250 app credits to try things out" — a trial allowance, not a monthly reset. FIG 01 — Where a metered app credit is spent, and where it is not / Arithmetic ## How long do 250 credits actually last? Long enough to build the thing, and rarely long enough to run it. Work it out at real volume rather than in the abstract: - A store taking **40 orders a day**, with one recipe that appends each order to a Google Sheet, spends 40 credits a day. - 250 ÷ 40 = **6.25 days**. The integration stops working inside a week. - Over a year that same recipe is 40 × 365 = **14,600 metered actions**. The free tier covers 250 of them, or **1.7%**. Now add a second external step — post the order to Slack as well — and each order costs 2 credits instead of 1. 250 ÷ 80 = **3.1 days**. Credits are consumed per action, not per trigger, so the burn rate scales with how many external systems a recipe touches, not with how many times it fires. None of this is hidden or unfair. It is how almost every automation product is priced. It is simply the number you want in front of you before you wire a high-frequency trigger to an external app. / Pricing ## What do the paid plans cost? Paid plans remove the meter: every Pro tier includes unlimited app integration usage. On the current [AI + automation plans](https://automatorplugin.com/pricing/) that is **$300/year** for 1 site (Basic), **$480/year** for 10 sites (Plus) and **$720/year** for 50 sites (Elite). The legacy automation-only plans, without the AI agent allowances, sit at **$199**, **$349** and **$599** per year for the same site counts. Per site that is 480 ÷ 10 = **$48/year** on Plus and 720 ÷ 50 = **$14.40** on Elite — so agency maths and single-site maths point in genuinely different directions. > The question is never "recipes or webhooks". It is whether the destination you need is already in someone's catalogue — and what it costs you every time it fires if it is. — the actual decision ![An augmented courier caught mid-decision between an over-augmented figure offering a single glowing coin and a plain wall pipe already pouring the same coins out uncounted, cyberpunk illustration](https://wpwebhooks.org/blog/uncanny-automator-alternative/og_image.jpg) / Where recipes win ## When is Uncanny Automator clearly the right tool? When the automation stays inside WordPress and both ends already exist as a trigger and an action. That is not a grudging concession — it is most of the real work on most WordPress sites. Concretely, it wins when: - **Both plugins are in the catalogue.** Enrol a LearnDash student when a WooCommerce order completes: two clicks, no code, no metering. - **A non-developer maintains it.** A recipe is legible in the admin. A hook in a PHP file is not, and it disappears the moment someone switches themes. - **The logic is conditional.** Pro tiers add filters and branching that you would otherwise write and test yourself. - **The volume is low.** Twenty external actions a month never gets near a meter, credited or not. / Where webhooks win ## When does a webhook beat a recipe? When the destination is not in the catalogue, or when the per-action cost stops being theoretical. A webhook has no catalogue to be absent from — you supply the URL, so an internal service, a partner API, a self-hosted n8n instance and a queue endpoint are all equally reachable. That is what decides it most often: the integration you need is the one nobody built a connector for. One precision that cuts against most comparison posts: Automator's own feature list advertises **unlimited outgoing webhooks** in the free tier. A webhook is not a metered app integration — there is no account to authorise — so a recipe ending in a webhook burns no credits either. If you already run Automator and hit the credit wall, pointing the last step at a webhook is a legitimate answer. Side by side against a dedicated webhook plugin, then. The first row decides most cases: | Concern | Uncanny Automator recipe | Webhook Actions | | --- | --- | --- | | Plugin-to-plugin automation | Its core strength — hundreds of native triggers and actions, unmetered | Works by calling your own site’s wp-json routes from a chain step — but only where the target plugin exposes one. No connector catalogue. | | Multi-step sequences | Recipes with multiple actions and, on Pro, branching logic | Webhook Chains — each 2xx fires the next step with the upstream response available, per-link conditions, own log and retry per hop | | Destination coverage | Whatever is in the app catalogue | Any HTTPS endpoint — you supply the URL, so internal services count | | Per-action cost | 1 app credit per external app step; 250 free, then Pro | Unmetered — dispatch is an HTTP request from your own server | | Payload shape | Fixed by the connector | Field mapping into whatever schema the receiver dictates | | Retries | Handled for you | Queued with exponential backoff — 1m, 2m, 4m, 8m, 5 attempts | | Delivery history | Run history in your Automator account | Per-attempt request/response log in wp-admin, with replay | | Who can maintain it | Anyone, in the recipe builder | Anyone, in the admin UI — or from a plain-language prompt | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / The alternative ## What does the webhook route actually look like? Every trigger a recipe builder offers is a WordPress [action hook](https://developer.wordpress.org/plugins/hooks/actions/) underneath. The practical question is who turns that hook into a delivered, retried, logged request — you, or something that does only that job. [Webhook Actions](https://wpwebhooks.org/wordpress-webhook-plugin/) is the free, GPL plugin behind this site, and it is the honest comparison here. You pick the same `do_action` the recipe would have used, map the fields against a payload actually captured from your site, and point it at a URL. Delivery is queued rather than inline, retried with exponential backoff — 1m, 2m, 4m, 8m over five attempts — and every attempt is logged with its request, response and a replay button. There is no per-action meter, because the request leaves your own server. Multi-step is **Webhook Chains**: a webhook completing with a 2xx fires the next one, handing it the upstream response body alongside the original payload, with per-link conditions and its own log and retry on every hop. You can also skip the configuration entirely — describe the automation in plain language and the built-in agent plans it, captures a real payload, maps the fields and sends a test delivery, with nothing live until you confirm. Where it is genuinely weaker, and this should decide it: **there is no connector catalogue.** Automator ships hundreds of native plugin-to-plugin actions; Webhook Actions reaches another plugin only by calling your own site’s `wp-json` route from a chain step, which works only if that plugin exposes one. If your automation is LearnDash-to-WooCommerce and both are in the catalogue, Automator is simply the better tool. / Or own the code ## What if you would rather write it yourself? Reasonable, and worth seeing first. The dispatch is about twenty lines: PHP — dispatch on order completion ``` add_action( 'woocommerce_order_status_completed', function( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } $payload = [ 'order_id' => $order->get_id(), 'total' => $order->get_total(), 'email' => $order->get_billing_email(), 'placed' => $order->get_date_created()->format( 'c' ), ]; $response = wp_remote_post( 'https://example.test/hooks/order', [ 'timeout' => 5, 'blocking' => true, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $payload ), ] ); // This is the part that decides whether you actually own this. if ( is_wp_error( $response ) ) { // Nobody is coming. No retry, no log, no replay. return; } } ); ``` The dispatch is the easy part. Everything after the `is_wp_error` check is what you have just taken on, and it is the same work both a hosted platform and a delivery plugin are doing for you: - **The call is synchronous.** As written, the customer waits for `example.test` to answer. A five-second timeout on a slow endpoint is five seconds added to checkout. The send belongs behind a queue, not inline — see [why WP-Cron alone is not enough for async webhooks](https://wpwebhooks.org/blog/async-webhooks-wordpress/). - **There is no retry.** A 503 from the receiver loses the event permanently. Doing this properly means a persistent queue, exponential backoff, an attempt cap and a terminal state — the pattern is spelled out in [the retry policy reference](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/). - **There is no log.** When someone asks in three weeks whether order 4,812 was delivered, you have no answer and no way to replay it. - **Failures are silent.** This is the single most common way a hand-rolled integration dies: it works on the day it ships and quietly stops months later, with the site giving no sign. [Silent webhook failure in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) covers the specific shapes it takes. That list is the actual product boundary. It is not "paid recipe builder versus free code" — it is a metered catalogue on one side and a queued, logged, retrying delivery path on the other, which you either build and maintain yourself or install. / Verdict ## Which one should you actually pick? **Uncanny Automator**, when the automation stays inside WordPress and both ends are in its catalogue. Enrolling a student when an order completes is two clicks, unmetered, and maintainable by someone who does not write PHP. Rebuilding that as webhooks would be a worse answer at any price. **A webhook plugin**, when the destination is an endpoint rather than a catalogue entry — your own service, a partner API, an n8n or Make node — or when the volume makes per-action metering the dominant cost, or when the receiver dictates a payload shape no connector will produce. **Hand-rolled code**, when you want to own the queue, the backoff and the log, and have budgeted for maintaining all three. Most sites end up with the first two, and there is nothing incoherent about that. The mistake is picking one before knowing which of the problems you actually have. /Footnotes ¹ Uncanny Automator knowledge base, [What are Uncanny Automator credits?](https://automatorplugin.com/knowledge-base/what-are-credits/) — definition of an app credit, the 250 free allowance, and unlimited usage on Pro plans. ² Install count and free-tier feature list from the [Uncanny Automator listing on WordPress.org](https://wordpress.org/plugins/uncanny-automator/), read 2026-08-28. ³ Plan prices and site counts from [automatorplugin.com/pricing](https://automatorplugin.com/pricing/), read 2026-08-28. Prices change; check before quoting. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Uncanny Automator Alternative: When to Use a Webhook","description":"An honest Uncanny Automator alternative guide: what app credits actually meter, where recipes win, and when a plain outgoing webhook is the cheaper path.","datePublished":"2026-08-28","dateModified":"2026-08-28","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/uncanny-automator-alternative/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/uncanny-automator-alternative/og_image.jpg","width":1200,"height":630,"caption":"An augmented courier caught mid-decision between an over-augmented figure offering a single glowing coin and a plain wall pipe already pouring the same coins out uncounted, cyberpunk illustration"},"keywords":["uncanny automator alternative","uncanny automator","uncanny automator pricing","uncanny automator app credits","wordpress automation plugin","wordpress webhook alternative"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Uncanny Automator Alternative: When to Use a Webhook","item":"https://wpwebhooks.org/blog/uncanny-automator-alternative/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is an Uncanny Automator app credit?","acceptedAnswer":{"@type":"Answer","text":"One credit is spent each time a recipe passes data to an external service over an authorised API connection, such as adding a row to Google Sheets or registering someone for a Zoom webinar. Steps that only touch WordPress plugins are not metered. A free account is granted 250 credits to try things out, and Pro plans include unlimited app integration usage."}},{"@type":"Question","name":"Are the 250 free app credits monthly or one-time?","acceptedAnswer":{"@type":"Answer","text":"One-time. The documentation describes them as credits granted to registered free users to try things out, not a recurring monthly allowance. A recipe firing on every order will exhaust them quickly, which is the moment most sites discover the metering exists."}},{"@type":"Question","name":"Does an outgoing webhook consume an app credit?","acceptedAnswer":{"@type":"Answer","text":"Credits apply to app integrations, which are the connections you authorise under Automator, App integrations. A webhook is not one of those: it is an HTTP request your own server makes to a URL you supply, with no account to link. Automator advertises unlimited outgoing webhooks among its free features."}},{"@type":"Question","name":"When is Uncanny Automator the better choice over a webhook?","acceptedAnswer":{"@type":"Answer","text":"When the automation stays inside WordPress and both ends already have a built-in integration. Enrolling a LearnDash student when a WooCommerce order completes is a two-click recipe and unmetered. Rebuilding it as a webhook means writing code for something already solved."}},{"@type":"Question","name":"When is a webhook the better choice?","acceptedAnswer":{"@type":"Answer","text":"When the destination has no integration in the catalogue, when the volume makes per-operation metering expensive, or when the payload has to be reshaped to a schema the receiving system dictates. A webhook has no catalogue to be absent from, because you supply the URL."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/uncanny-automator-alternative.png","caption":"FIG 01 — Where a metered app credit is spent, and where it is not","description":"A recipe runs entirely inside WordPress until it has to talk to an outside service. Plugin-to-plugin steps such as adding a user to a group or marking a course complete are unmetered. The moment a step passes data to a third-party app over an authorised API connection, such as appending a row to Google Sheets or registering someone for a Zoom webinar, it spends one app credit. A free account is granted 250 of them once, not monthly. An outgoing webhook is a plain HTTP request made by your own server and is not one of the authorised app integrations, so the metering does not apply to it.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Form to Google Sheets: The Sheets API v4 Way" description: "Send WordPress form entries to Google Sheets with the API: service account auth, the sharing step that 403s, valueInputOption, and the 60 writes per minute cap." url: "https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/" date: "2026-08-26" --- # WordPress Form to Google Sheets: The Sheets API v4 Way **TL;DR:** One endpoint does the whole job, and one non-code step is what breaks most integrations. - `POST /v4/spreadsheets/{spreadsheetId}/values/{range}:append` with `valueInputOption` and `insertDataOption=INSERT_ROWS`. - Authenticate with a **service account**: sign a JWT, exchange it for a bearer token, cache the token. - **Share the spreadsheet with the service account's email address.** Skip this and every call returns 403 with perfectly valid credentials. - Limits are **300 writes per minute per project** and **60 per minute per user**. Over that is a 429, and Google asks for truncated exponential backoff. / Overview ## What does it take to write a form entry into Google Sheets? Three things: a credential Google will accept, one HTTP call, and a sharing step that has nothing to do with code. The call is the easy part and the sharing step is where most integrations die, so it is worth taking them in that order. There is exactly one endpoint you need. The [values.append method](https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets.values/append) takes a spreadsheet ID, a range in A1 notation, and an array of rows, and adds them after the last row of the table it finds. You do not have to know how many rows already exist, and you do not have to read before you write. Everything else — which form plugin fired, how the fields are named, whether it is a WooCommerce order rather than a form at all — is just the shape of the array you hand it. / Auth ## Which credential should a WordPress site use? A [service account](https://developers.google.com/identity/protocols/oauth2/service-account), not OAuth. The distinction matters: OAuth authorises _a person_ and needs a browser consent screen plus refresh-token handling. A service account authorises _the server_, which is what a form handler running at 3am actually is. Create one in the Google Cloud console, enable the Sheets API on the project, and download the JSON key. Two fields in that file matter — `client_email` and `private_key`. The flow is: build a JWT asserting who you are and what scope you want, sign it with the private key, and exchange it at Google's token endpoint for a bearer token that lasts about an hour. The scope for writing values is `https://www.googleapis.com/auth/spreadsheets`. The append endpoint also accepts the broader `drive` and `drive.file` scopes, but there is no reason to ask for the ability to touch every file in a Drive when you only need one spreadsheet. FIG 02 — Service-account auth and the append call, including the step that fails silently / The 403 ## Why does a valid credential still return 403? Because a service account owns nothing. It is a separate identity with its own email address, and it has no more access to your spreadsheet than a stranger does — a perfectly valid token proves who it is, not that it is allowed in. The fix is a sharing step in the Google Sheets UI, not a code change: open the sheet, click Share, paste the `client_email` value from the JSON key, and give it **Editor**. The address looks like `something@your-project.iam.gserviceaccount.com` and Sheets will treat it like any other collaborator. This is worth stating plainly because the failure is so misleading. The token request succeeds. The credentials are correct. The API key is not the problem, the scope is not the problem, and every debugging instinct points at the auth code — which is fine. The sheet was simply never shared. **Gotcha:** a spreadsheet created _by_ the service account has the opposite problem — the service account owns it and no human can see it, because it was never shared with you. Create the sheet as yourself, then share it with the service account. ![A cybernetic archivist kneeling to lay one bright new row at the foot of a towering wall of lit ledger rows, her other palm on a reader plate fractured with magenta light, cyberpunk illustration](https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/og_image.jpg) / The call ## What does the append request look like? A POST with the values in the body and the behaviour in the query string. Two query parameters decide what actually lands in the cells. PHP — append a row to a sheet ``` function sheets_append_row( string $spreadsheet_id, string $range, array $row, string $token ) { $url = sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s:append', rawurlencode( $spreadsheet_id ), rawurlencode( $range ) // e.g. 'Entries!A:E' ); $url = add_query_arg( [ 'valueInputOption' => 'USER_ENTERED', // or RAW — see below 'insertDataOption' => 'INSERT_ROWS', // never OVERWRITE for form data ], $url ); return wp_remote_post( $url, [ 'timeout' => 10, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], // A ValueRange: values is an ARRAY OF ROWS, not a flat array. 'body' => wp_json_encode( [ 'values' => [ $row ] ] ), ] ); } ``` Two details cause most of the confusion. `values` is an **array of rows**, so a single row is `[[a, b, c]]` and not `[a, b, c]` — passing the flat version writes one value per row down a column. And `insertDataOption` defaults to `OVERWRITE`, which will happily write over existing cells below your table; `INSERT_ROWS` is what you want for anything append-shaped. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Value parsing ## What is the difference between RAW and USER\_ENTERED? `RAW` stores exactly the string you send. `USER_ENTERED` parses it the way the Sheets UI would if a person typed it — so `2026-08-28` becomes a real date, `3` becomes a number, and `=SUM(A1:A2)` becomes an actual formula. For form data that is a genuine trade-off rather than a preference: | Input | RAW | USER\_ENTERED | | --- | --- | --- | | 2026-08-28 | Text "2026-08-28" | A date value, sortable | | 00123 | Text "00123" — leading zeros kept | Number 123 — zeros lost | | +48 22 000 00 00 | Text, intact | May be parsed as a number | | \=SUM(A1:A2) | Literal text | An executable formula | The last row is the one to think about. Every value in a form submission was typed by a stranger, and `USER_ENTERED` is the setting that turns a stranger's string into a formula in your spreadsheet. If any field is free text, either use `RAW` for that column or prefix the value with an apostrophe so Sheets treats it as text. / Limits ## What are the rate limits, and what happens at the boundary? Google publishes them plainly.¹ Per project: **300 read requests per minute** and **300 write requests per minute**. Per user per project: **60 of each per minute**. Exceeding a quota "generates a `429: Too many requests` HTTP status code response". The per-user figure is the one that binds in practice, because a service account is a single user. 60 writes per minute is **1 per second** — comfortable for a contact form, and not comfortable at all for a bulk import or a flash sale. Two arithmetic checks worth doing before you ship: - Importing 5,000 historical entries one row at a time: 5,000 ÷ 60 = **83 minutes** of sustained requests, assuming nothing else uses the quota. - Batching 100 rows per call instead: 5,000 ÷ 100 = 50 requests, which fits inside a single minute with room to spare. The endpoint already accepts multiple rows in one call, so batching is free — you were sending an array of rows all along. On a 429, Google's guidance is explicit: "you should use an exponential backoff algorithm", specifically truncated exponential backoff of the form `min(((2^n) + random_number_milliseconds), maximum_backoff)` with a maximum around 32 or 64 seconds. The random component is not decorative — without it, every queued job that failed together retries together. / What Google does not do for you ## What is left for you to handle? This is the section every Sheets tutorial skips, and it is the part that decides whether the integration is still working in six months. - **Nothing retries.** The API returns a status and that is the end of its involvement. A 429 or a 503 loses the row unless you queue and retry it yourself. - **The private key is a full credential in your database.** Anyone who can read `wp_options` — or a backup of it — can write to that spreadsheet. Store it outside the database if you can, and never in a theme file. - **A renamed tab breaks the range silently.** `Entries!A:E` is a string. Rename the tab in the UI and every call returns 400 with no warning that anything upstream changed. - **There is no schema.** Sheets will not reject a row with the columns in the wrong order — it writes it. The data is simply wrong from that point on, and nothing surfaces an error. - **The 10-million-cell ceiling is real.** A high-volume form append will reach it eventually, and the failure arrives as a hard stop rather than a warning. / Placement ## Should the call run inline on submission? No. An inline call makes the visitor wait for Google, and a rate-limited or slow response becomes a slow form — or a form that appears to fail when the entry was actually saved. The submission and the delivery are two different jobs with two different reliability requirements. Queue the write. Capture the entry on the form plugin's completion hook, hand the payload to a background worker, and let that worker own the token refresh, the 429 backoff and the attempt log. WordPress's own [WP-Cron](https://developer.wordpress.org/plugins/cron/) is the obvious first stop and is also not enough on its own, for reasons worth reading before you rely on it: [a proper job queue in WordPress](https://wpwebhooks.org/blog/wordpress-job-queue/) and [why WP-Cron alone will not carry async delivery](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/). Do that, and a Google outage becomes a delayed row instead of a lost customer. /Footnotes ¹ Quota figures and the 429 wording from [Usage limits, Google Sheets API](https://developers.google.com/workspace/sheets/api/limits), read 2026-08-28. ² Request shape, query parameters and scopes from the [spreadsheets.values.append reference](https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets.values/append). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Form to Google Sheets: The Sheets API v4 Way","description":"Send WordPress form entries to Google Sheets with the API: service account auth, the sharing step that 403s, valueInputOption, and the 60 writes per minute cap.","datePublished":"2026-08-26","dateModified":"2026-08-26","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/og_image.jpg","width":1200,"height":630,"caption":"A cybernetic archivist kneeling to lay one bright new row at the foot of a towering wall of lit ledger rows, her other palm on a reader plate fractured with magenta light, cyberpunk illustration"},"keywords":["wordpress form to google sheets","wordpress google sheets integration","google sheets api wordpress","contact form 7 to google sheets","woocommerce to google sheets","google sheets service account"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Form to Google Sheets: The Sheets API v4 Way","item":"https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do you append a row to Google Sheets from WordPress?","acceptedAnswer":{"@type":"Answer","text":"POST to https://sheets.googleapis.com/v4/spreadsheets/{spreadsheetId}/values/{range}:append with a bearer token, valueInputOption set to USER_ENTERED or RAW, and insertDataOption set to INSERT_ROWS. The request body is a ValueRange object whose values key holds an array of row arrays."}},{"@type":"Question","name":"Why does the Google Sheets API return 403 when my credentials are valid?","acceptedAnswer":{"@type":"Answer","text":"Because the spreadsheet was never shared with the service account. A service account is a separate identity that owns nothing by default, so a valid token still grants no access to your file. Copy the client_email from the JSON key and share the sheet with that address as an Editor."}},{"@type":"Question","name":"What is the difference between RAW and USER_ENTERED?","acceptedAnswer":{"@type":"Answer","text":"RAW stores exactly the string you send, so 2026-08-28 stays text and =SUM(A1:A2) stays a literal. USER_ENTERED parses the value the way the Sheets UI would, turning it into a date or an actual formula. For form data USER_ENTERED is usually right for numbers and dates, and dangerous for anything a visitor typed."}},{"@type":"Question","name":"What are the Google Sheets API rate limits?","acceptedAnswer":{"@type":"Answer","text":"Three hundred read requests and three hundred write requests per minute per project, and sixty of each per minute per user per project. Exceeding a quota returns HTTP 429, and Google recommends truncated exponential backoff rather than an immediate retry."}},{"@type":"Question","name":"Should the Sheets call run inline on form submission?","acceptedAnswer":{"@type":"Answer","text":"No. An inline call ties the visitor to Google being reachable, and a slow or rate-limited response becomes a slow or broken form. Queue the write and let a background worker perform it, so a 429 turns into a retry rather than a lost entry."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-to-google-sheets.png","caption":"FIG 02 — Service-account auth and the append call, including the step that fails silently","description":"The site signs a JWT with the service account private key and exchanges it at the Google token endpoint for a short-lived bearer token, which is cached until shortly before it expires. The append call is a POST to the values append endpoint carrying valueInputOption and insertDataOption. The step that catches most integrations is the one that involves no code at all: the spreadsheet must be shared with the service account email address, because a service account is a separate identity that owns nothing by default. Skip it and every request returns 403 with a permission denied error even though the credentials are valid. Exceeding 60 write requests per minute per user returns 429 and requires truncated exponential backoff.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "OttoKit (SureTriggers) Alternative: Webhooks vs Tasks" description: "An OttoKit and SureTriggers alternative guide: what a monthly task allowance meters, and when self-hosting the delivery is the better fit." url: "https://wpwebhooks.org/blog/ottokit-suretriggers-alternative/" date: "2026-08-23" --- # OttoKit (SureTriggers) Alternative: Webhooks vs Tasks **TL;DR:** OttoKit is SureTriggers renamed. It is a hosted platform, and that one fact drives every trade-off worth caring about. - Same plugin, same slug — the WordPress.org listing reads _OttoKit_, the directory URL still says `suretriggers`, and it carries **90,000+** active installs. - Workflows execute on the vendor's cloud and are metered as **tasks**, counted per executed step rather than per trigger. - When the monthly allowance runs out, workflows stop. The site gives no sign — forms still submit, orders still complete, nothing downstream happens. - A self-hosted webhook has no meter and no third party in the path, and hands you the retry, logging and backoff problem in exchange. / Identity ## Is OttoKit the same product as SureTriggers? Yes. SureTriggers was rebranded to OttoKit, and the [WordPress.org listing](https://wordpress.org/plugins/suretriggers/) now reads "OttoKit: All-in-One Automation Platform" while keeping the original `suretriggers` slug. Existing installs were not disturbed by the rename and the old directory URL still resolves. It comes from Brainstorm Force, the team behind Astra, Spectra and CartFlows, and the listing reports **90,000+** active installations. The vendor describes the platform as supporting 1,400+ integrations across WordPress plugins and external apps.¹ Worth knowing if you are searching: results written before the rename discuss the same product under the old name, and results written after sometimes quote task allowances that have since changed. Check the current pricing page rather than a review. / Architecture ## Where does an OttoKit workflow actually run? On the vendor's infrastructure, not on your server. The WordPress plugin is the part that notices a trigger fired and reports it; the workflow itself — the conditions, the branching, the calls to other apps — executes in the OttoKit cloud, and the run history lives in your OttoKit account. That is a real architectural choice with real advantages. Your server does no work beyond emitting the trigger, so a slow third-party API never becomes a slow WordPress request. Retries, logging and replay are the vendor's problem. A workflow keeps running while your site is under load. It also means three things follow automatically: the data leaves your infrastructure, the execution is metered, and the platform is a dependency in the path between your site and its destination. FIG 01 — Task-metered platform run versus a self-hosted webhook / Metering ## What is a task, and why does the count grow faster than you expect? A task is a unit of executed work, and the important detail is that platforms of this shape count _steps_, not _events_. One trigger with three actions is not one task. Run the arithmetic on a plausible workflow — an order completes, and the platform adds a CRM contact, appends a spreadsheet row and posts to Slack: - That is **3 billable steps per order**, plus the trigger depending on how the vendor counts it. - At 40 orders a day: 40 × 3 = **120 tasks a day**, or 120 × 30 = **3,600 a month**. - A 1,000-task allowance covers 1,000 ÷ 3 = **333 orders** — about 8 days of that traffic. - Add a fourth action and the same allowance covers 250 orders instead. Each extra step cuts the ceiling proportionally. None of that is a criticism of the pricing, which is ordinary for the category. It is a warning about how the estimate is usually made: people size a plan against how often the trigger fires, and get billed for how many steps run. Vendors differ on which steps are billable — some exclude filters and conditions, some count them — so the only number that matters is the one in the vendor's own definition. Check the current [OttoKit pricing page](https://ottokit.com/pricing/) for the live allowances rather than trusting a figure quoted in a review, including this one. **The failure mode to design for:** when a task allowance is exhausted, workflows stop but the site does not. Forms still submit, orders still complete, customers see nothing wrong — and nothing downstream runs. Unless you are watching the vendor dashboard, the first signal is a colleague asking why the CRM has been empty since the 14th. ![An augmented courier on a rooftop gantry looking back at a dead black platform overhead as luminous moths thin out beneath it, while a conduit at her feet keeps pouring more, cyberpunk illustration](https://wpwebhooks.org/blog/ottokit-suretriggers-alternative/og_image.jpg) / Where hosted wins ## When is a hosted platform the right answer? More often than developers like to admit. The catalogue is the product, and 1,400 maintained connectors represent an enormous amount of work you do not have to do or keep doing. It wins clearly when: - **The destinations are mainstream and numerous.** Wiring six popular SaaS tools together is an afternoon in a visual builder and weeks in code. - **Non-developers own the automations.** A workflow on a canvas can be changed by the person who understands the business rule. - **You want run history for free.** Logs, replay and retries are built in. Rebuilding that yourself is the single most underestimated part of rolling your own. - **Volume is modest.** Below the allowance, metering is a number you never look at. - **Connector maintenance is someone else's job.** When an API version changes, the vendor updates the connector. / Where self-hosted wins ## When does a self-hosted webhook win instead? When one of three things is true, and they are easy to test for. **Volume makes metering the dominant cost.** If the arithmetic above put you in five figures of monthly tasks, the per-task price is now the main line item, and an HTTP request from your own server costs nothing per call. **The data cannot leave your infrastructure.** A hosted platform necessarily sees the payload. For health, financial or otherwise regulated data that is a compliance question before it is a technical one, and it is not answered by a checkbox. **The destination will never be in a catalogue.** An internal service, a partner's bespoke API, a queue endpoint, a self-hosted n8n instance. You supply the URL, so there is nothing to be supported. A fourth, quieter one: dependency count. A hosted platform is a third party sitting between your site and its destination, with its own uptime and its own incident history. Sometimes that is a better-run system than yours. Sometimes it is one more thing that can be down at 2am while you have no way to intervene. Against a dedicated webhook plugin rather than against hand-written code — the hand-rolled path gets its own section below. Read the first row before the rest: these are adjacent tools, not competing ones. | Concern | OttoKit workflow | Webhook Actions | | --- | --- | --- | | Multi-step workflows | A visual canvas — branching, conditions, 1,400+ connectors | Webhook Chains — each 2xx fires the next step with the upstream response available, per-link conditions, cycle-checked at save | | Wiring two WordPress plugins together | Native connectors, no endpoint needed | A chain step calls your own site’s wp-json route — so it works only where the target plugin exposes one | | Where the work runs | Vendor cloud — your server stays idle | Your own server, out of a queue in your database | | Cost model | Metered per executed step | Unmetered — no per-run counter, because no third party is in the path | | Data path | Payload transits the vendor | Site straight to the destination | | Destination coverage | The app catalogue, 1,400+ integrations | Any HTTPS endpoint, including an internal service no catalogue will list | | Retries | Built in | Exponential backoff — 1m, 2m, 4m, 8m, 5 attempts, then permanently failed | | Run history | In your OttoKit account | In your own database — full attempt log with replay and bulk retry | | When the meter runs out | Workflows stop, silently | No meter to run out | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / The alternative ## What does self-hosting the delivery actually look like? [Webhook Actions](https://wpwebhooks.org/wordpress-webhook-plugin/) is the free, GPL plugin behind this site, and it is the honest comparison to draw here. Any [action hook](https://developer.wordpress.org/plugins/hooks/actions/) on the site becomes a trigger, the payload is mapped against a real captured example, and delivery runs out of a queue in your own database — retried with exponential backoff over 1m, 2m, 4m and 8m across five attempts, then marked permanently failed rather than retried forever. Every attempt is logged with its request and response, and any logged event can be replayed. Multi-step is **Webhook Chains**: a 2xx completion fires the next webhook and hands it the upstream response body, with per-link conditions, cycle detection at save time, and a separate log and retry on each hop. Nothing is metered, because no third party sits in the path — the request leaves your server for the destination directly. The two honest limits, and they are the ones that should decide it: **there is no 1,400-app catalogue**, so a destination that needs OAuth and a maintained connector is work you do rather than work you buy; and **plugin-to-plugin means calling your own site’s `wp-json` route** from a chain step, which only works where the target plugin exposes one. A visual canvas with branching across dozens of SaaS tools is a different product, and for that job it is the better one. / Or own the code ## What if you skip the plugin too? The dispatch is trivial — a [`wp_remote_post`](https://developer.wordpress.org/reference/functions/wp_remote_post/) call on any action hook. Everything a hosted platform charges you the task price for is what remains, and it is now yours: - **A persistent queue**, so the send is not inline and the visitor never waits for a third party. WP-Cron is the starting point and is not sufficient alone — [the reasons are specific](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/). - **Retry with backoff and a terminal state**, so a 503 is a delay rather than a lost event, and a 400 is not retried forever. The [retry policy reference](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/) covers the shape. - **An attempt log with replay**, so "did order 4,812 arrive" has an answer. - **Failure visibility**, which is the one people skip and the one that matters. A hand-rolled integration that dies quietly is worse than no integration, because you believe it is working — see [silent webhook failure in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/). Notice that the last item is the same failure mode as an exhausted task allowance. Neither approach is inherently safe from it; the difference is only whose dashboard you have to watch. > You are not choosing between paying and not paying. You are choosing whether the queue, the retries and the run log are a line item on an invoice or a line item on your backlog. — the actual decision / Verdict ## Which should you choose? **OttoKit**, when the catalogue covers your destinations, the volume sits inside an allowance you have actually calculated, and you would rather buy the run history than build it. That is a good trade and plenty of sites should take it. **A self-hosted webhook plugin**, when the arithmetic puts you well past the allowance, when the payload cannot legally transit a third party, or when the thing you need to reach is yours and always will be — an internal service, a partner API, a queue endpoint. You get the queue, the retries and the log without building them, and without a meter. **Your own code**, only if you want to own all three and have budgeted for maintaining them. "Later" is how that decision ends up failing silently for a month. And size the plan on _steps per event, times events_, not on how often the trigger fires. That single arithmetic error is the most expensive one in this whole category. /Footnotes ¹ Product name, install count and integration count from the [OttoKit listing on WordPress.org](https://wordpress.org/plugins/suretriggers/), read 2026-08-28. ² Plan tiers and task allowances change; the current figures are on [ottokit.com/pricing](https://ottokit.com/pricing/). The worked examples above deliberately use round numbers rather than quoting a tier. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"OttoKit (SureTriggers) Alternative: Webhooks vs Tasks","description":"An OttoKit and SureTriggers alternative guide: what a monthly task allowance meters, and when self-hosting the delivery is the better fit.","datePublished":"2026-08-23","dateModified":"2026-08-23","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/ottokit-suretriggers-alternative/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/ottokit-suretriggers-alternative/og_image.jpg","width":1200,"height":630,"caption":"An augmented courier on a rooftop gantry looking back at a dead black platform overhead as luminous moths thin out beneath it, while a conduit at her feet keeps pouring more, cyberpunk illustration"},"keywords":["ottokit alternative","suretriggers alternative","ottokit","suretriggers","wordpress automation platform","ottokit pricing"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"OttoKit (SureTriggers) Alternative: Webhooks vs Tasks","item":"https://wpwebhooks.org/blog/ottokit-suretriggers-alternative/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is OttoKit the same product as SureTriggers?","acceptedAnswer":{"@type":"Answer","text":"Yes. SureTriggers was renamed OttoKit and the WordPress.org listing now reads OttoKit, All-in-One Automation Platform. The plugin slug is still suretriggers, so an existing install keeps working and the directory URL still resolves under the old name."}},{"@type":"Question","name":"What is a task in a hosted automation platform?","acceptedAnswer":{"@type":"Answer","text":"A unit of work counted against your monthly allowance, typically one executed step in a workflow. A workflow with a trigger and three actions consumes more than one, so allowance maths tracks steps rather than events. Vendors differ on which steps are billable, so read their own definition before sizing a plan."}},{"@type":"Question","name":"What happens when the monthly task allowance runs out?","acceptedAnswer":{"@type":"Answer","text":"Workflows stop running until the allowance resets or the plan is raised. This is the failure mode worth planning for, because it is silent from the visitor side: the form still submits and the order still completes, but nothing downstream happens."}},{"@type":"Question","name":"When does a hosted platform beat a self-hosted webhook?","acceptedAnswer":{"@type":"Answer","text":"When you want many destinations wired quickly without writing code, when non-developers maintain the automations, and when you want run history, replay and retries handled for you. A large integration catalogue is real work you do not have to do."}},{"@type":"Question","name":"When does a self-hosted webhook beat a hosted platform?","acceptedAnswer":{"@type":"Answer","text":"When volume makes per-task pricing the dominant cost, when data cannot leave your infrastructure, or when the destination is an internal service the catalogue will never contain. The trade is that queueing, retries and logging become your responsibility."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/ottokit-alternative.png","caption":"FIG 01 — Task-metered platform run versus a self-hosted webhook","description":"On a hosted platform the trigger leaves WordPress and the workflow executes on the vendor cloud, where each step that runs is counted against a monthly task allowance and the run history lives in the vendor account. When the allowance is exhausted the workflows stop until the month resets or the plan is raised. A self-hosted webhook keeps the whole path on your own server: the trigger fires, a queue holds the delivery, and your server posts straight to the destination. There is no per-run counter because no third party sits in the path, and the delivery log stays in your database. The trade is that retries, logging and backoff are now your responsibility rather than the vendor.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Contact Form 7 to Airtable: Send Entries With the API" description: "Send Contact Form 7 entries to Airtable with the REST API: personal access token auth, field mapping, typecast, the 10-record cap and 5 req/s limit." url: "https://wpwebhooks.org/blog/contact-form-7-to-airtable/" date: "2026-08-20" --- # Contact Form 7 to Airtable: Send Entries With the API **TL;DR:** - `wpcf7_mail_sent` hands you the form object, not the data — read values with `WPCF7_Submission::get_instance()->get_posted_data()`. - Airtable creates records at `POST https://api.airtable.com/v0/{baseId}/{tableIdOrName}` with a personal access token as a bearer credential. - Every key you send must already exist as a field in the table. Airtable rejects the whole request otherwise — it never creates columns for you. - The hard ceilings: **10 records per request**, **5 requests per second per base**, and a **30-second** wait after a 429. - Sending inline makes the visitor wait for Airtable and loses the entry if it is down. Queue it. / Overview ## How do you send Contact Form 7 entries to Airtable? Hook `wpcf7_mail_sent`, read the submitted values out of `WPCF7_Submission`, map each form-tag name onto the matching Airtable field name, and POST the result to Airtable's create-records endpoint with a personal access token. There is no official Contact Form 7 add-on for this and no Airtable-side listener — the whole integration is one hook and one HTTP request. The part that catches people is not the request. It is that **Airtable will not invent columns**. A form tag called `your-message` does not become a field called `your-message`; if that field is not already in the table, the API answers 422 and nothing is stored. The mapping step is the integration. / The hook ## Which Contact Form 7 hook gives you the submitted data? `wpcf7_mail_sent` fires after the mail component has successfully sent, and it receives **one** argument — the `WPCF7_ContactForm` object. That object describes the form, not the submission, which is why reading `$contact_form` for field values returns nothing useful. The submitted values live on the submission singleton instead. We cover the hook itself in depth in the [wpcf7\_mail\_sent reference](https://wpwebhooks.org/blog/wordpress-wpcf7-mail-sent-hook/); the short version is below, verified against Contact Form 7 6.1.6.¹ PHP — reading the submission ``` add_action( 'wpcf7_mail_sent', function( $contact_form ) { $submission = WPCF7_Submission::get_instance(); if ( ! $submission ) { return; // no submission in scope — nothing to send } $data = $submission->get_posted_data(); // $data is a flat array keyed by form-tag name: // [ 'your-name' => 'Ada', 'your-email' => 'ada@example.com', ... ] // Only act on the form you mean — get_posted_data() is shared. if ( 42 !== $contact_form->id() ) { return; } my_queue_airtable_record( $data ); } ); ``` Two details matter. `get_posted_data()` returns values that Contact Form 7 has already sanitised, but it returns _every_ field including hidden ones and the CF7 internals — pass through only the keys you mean. And the callback runs for every form on the site, so gate on `$contact_form->id()` unless you genuinely want all of them. FIG 01 — One Contact Form 7 submission becoming an Airtable record / The request ## What does the Airtable create-records request look like? One POST, one bearer token, one JSON body. The endpoint is `https://api.airtable.com/v0/{baseId}/{tableIdOrName}` — the base ID starts with `app`, and the table segment accepts either the table ID (starting `tbl`) or its display name, URL-encoded.² PHP — creating the record ``` function my_send_to_airtable( array $fields ) { $base = 'appXXXXXXXXXXXXXX'; $table = rawurlencode( 'Leads' ); $response = wp_remote_post( "https://api.airtable.com/v0/{$base}/{$table}", [ 'timeout' => 15, 'headers' => [ 'Authorization' => 'Bearer ' . MY_AIRTABLE_PAT, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( [ 'records' => [ [ 'fields' => $fields ] ], 'typecast' => true, ] ), ] ); if ( is_wp_error( $response ) ) { return $response; // transport failure — retry this one } $code = wp_remote_retrieve_response_code( $response ); return 200 === $code ? true : $code; } ``` `typecast: true` is the flag worth understanding. With it off — the default — Airtable requires values in exactly the right shape, so posting the string `"3"` to a number field fails. With it on, Airtable does "best-effort automatic data conversion from string values", which is what you want when the source is a web form, because **every value from an HTML form arrives as a string**. It will also create new select options on the fly rather than rejecting them. / Mapping ## How do you map form-tag names onto Airtable fields? Explicitly, in one array, and nowhere else. The temptation is to name the CF7 form tags after the Airtable columns and pass the array straight through. That breaks the first time somebody renames a column in Airtable or adds a field to the form, and it fails silently in the direction that loses data. PHP — an explicit map ``` // form-tag name => Airtable field name $map = [ 'your-name' => 'Name', 'your-email' => 'Email', 'your-subject' => 'Subject', 'your-message' => 'Message', ]; $fields = []; foreach ( $map as $tag => $column ) { if ( ! isset( $data[ $tag ] ) ) { continue; } $value = $data[ $tag ]; // CF7 gives arrays for checkboxes and multi-selects $fields[ $column ] = is_array( $value ) ? implode( ', ', $value ) : (string) $value; } $fields['Submitted'] = current_time( 'c' ); // ISO 8601 with offset ``` Checkbox and multi-select tags come back as PHP arrays. Airtable will accept an array for a multiple-select field, but a plain text field wants a string — flattening with `implode()` is the safe default until you know the column type. > Airtable never creates a column for you. If the field name in your payload is not already in the table, the entire record is rejected — not the field. / Limits ## What are Airtable's real limits? These are the numbers that decide your architecture, all from Airtable's own API reference.³ | Limit | Value | What happens at the boundary | | --- | --- | --- | | Records per create request | 10 | Request rejected — split into batches of 10 | | Requests per second, per base | 5 | 429 returned | | Requests per second, per token | 50 | 429 returned, across every base | | Wait after a 429 | 30 seconds | Earlier retries keep failing | | Field must already exist | always | 422 — the whole record is dropped | Run the arithmetic before you decide this is generous. Five requests per second per base is 5 × 60 = **300 records a minute** if you send one record per request, or 3,000 a minute batched at 10. A contact form will never approach that. A WooCommerce store replaying 8,000 historical orders will hit it in the first 30 seconds — 8,000 ÷ 10 = 800 requests, and at 5/s that is 160 seconds of sustained traffic with no headroom for anything else touching the same base. / Gaps ## What does Airtable not protect you from? The API is well behaved. The gaps are all on your side of the wire: - **No idempotency key.** There is no request header that says "this is the same record as before". Post twice and you get two records. Any retry you write must decide for itself whether the previous attempt landed — the cheapest fix is a deterministic key column you can search before inserting. - **The token is a bearer credential with broad reach.** A personal access token scoped to a base can read every table in it, not just the one you write to. Scope it to the single base, give it only `data.records:write`, and keep it out of the database and out of version control. - **A 200 is not a validated record.** With `typecast` on, a malformed date can be coerced into something Airtable accepts but you did not mean. Validate before sending, not after. - **Schema drift is silent until it is fatal.** Nobody tells WordPress that a column was renamed. The first sign is a run of 422s in a log you are probably not reading. ![A technician recoiling as a form submission shatters against one sealed bay in a glowing wall of record slots, cyberpunk illustration](https://wpwebhooks.org/blog/contact-form-7-to-airtable/og_image.jpg) / Delivery ## Should the request run inline or on a queue? Queue it. The inline version — calling Airtable directly inside `wpcf7_mail_sent` — ties the visitor's thank-you message to a third party being fast and reachable. With `'timeout' => 15`, an Airtable incident adds up to 15 seconds to a form submission, and if the request fails there is no second attempt: the entry is gone, because the only copy was in a PHP variable. This is the same failure shape we walked through in [why WordPress webhooks silently fail in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/). The fix is not a longer timeout, it is moving the send off the request. | Concern | Inline wp\_remote\_post | Queued delivery | | --- | --- | --- | | Visitor wait | Blocks until Airtable answers | Returns immediately | | Airtable down | Entry lost — no copy kept | Stays queued, retried later | | 429 handling | One attempt, then gone | Backs off and retries | | Evidence | Nothing unless you log it | Per-attempt request and response log | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Retries ## How do you handle a 429 without losing the entry? Treat 429 and 5xx as retryable and everything else as final. Airtable is explicit that after a rate-limit breach you must **wait 30 seconds** before requests succeed again, so a retry one second later is guaranteed to fail and burns an attempt. A 422 is the opposite case: the field name is wrong or the value is invalid, and retrying the identical payload will fail identically forever. Send it to a dead-letter list a human reads. The general shape of a backoff that behaves — and why capping it matters — is covered in the [retry policy and exponential backoff](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/) write-up. /Footnotes ¹ Hook signature `do_action( 'wpcf7_mail_sent', $contact_form )` and the `get_posted_data()` / `get_instance()` methods verified directly in `includes/submission.php` of [Contact Form 7](https://wordpress.org/plugins/contact-form-7/) 6.1.6. First-party documentation: [contactform7.com/docs](https://contactform7.com/docs/). ² Endpoint, bearer auth, request body and the `typecast` option: [Airtable create records API](https://airtable.com/developers/web/api/create-records). ³ Five requests per second per base, fifty per token, and the thirty-second wait after a 429: [Airtable rate limits](https://airtable.com/developers/web/api/rate-limits). ⁴ WordPress HTTP helpers used above: [wp\_remote\_post()](https://developer.wordpress.org/reference/functions/wp_remote_post/) and [is\_wp\_error()](https://developer.wordpress.org/reference/functions/is_wp_error/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Contact Form 7 to Airtable: Send Entries With the API","description":"Send Contact Form 7 entries to Airtable with the REST API: personal access token auth, field mapping, typecast, the 10-record cap and 5 req/s limit.","datePublished":"2026-08-20","dateModified":"2026-08-20","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/contact-form-7-to-airtable/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/contact-form-7-to-airtable/og_image.jpg","width":1200,"height":630,"caption":"A technician recoiling as a form submission shatters against one sealed bay in a glowing wall of record slots, cyberpunk illustration"},"keywords":["cf7 airtable","contact form 7 airtable","contact form 7 to airtable","wordpress airtable integration","airtable api wordpress","wordpress airtable plugin"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Contact Form 7 to Airtable: Send Entries With the API","item":"https://wpwebhooks.org/blog/contact-form-7-to-airtable/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Can Contact Form 7 send entries to Airtable without a plugin?","acceptedAnswer":{"@type":"Answer","text":"Yes. Hook wpcf7_mail_sent, read the values with WPCF7_Submission::get_instance()->get_posted_data(), map them onto your Airtable field names and POST to https://api.airtable.com/v0/{baseId}/{tableIdOrName} with a personal access token. No add-on is required on either side."}},{"@type":"Question","name":"Why does Airtable return a 422 when I create a record?","acceptedAnswer":{"@type":"Answer","text":"Almost always because a key in your payload is not an existing field in that table. Airtable never creates columns for you, and it rejects the entire record rather than the unknown field. Check for typos and for columns that were renamed in the Airtable UI after the integration was written."}},{"@type":"Question","name":"What does typecast do in the Airtable API?","acceptedAnswer":{"@type":"Answer","text":"With typecast set to true, Airtable performs best-effort automatic conversion from string values — so the string \"3\" can go into a number field and a new select option is created rather than rejected. It defaults to false. Because every value from an HTML form arrives as a string, typecast is normally what you want for form data."}},{"@type":"Question","name":"How many records can I create in one Airtable request?","acceptedAnswer":{"@type":"Answer","text":"Ten. Larger imports must be split into batches of 10, and those batches are still bound by the rate limit of 5 requests per second per base."}},{"@type":"Question","name":"What is the Airtable rate limit and what happens if I exceed it?","acceptedAnswer":{"@type":"Answer","text":"Five requests per second per base, and 50 per second across all traffic from one personal access token. Exceeding it returns HTTP 429, after which you must wait 30 seconds before requests succeed again — retrying sooner keeps failing."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/contact-form-7-to-airtable.png","caption":"FIG 01 — One Contact Form 7 submission becoming an Airtable record","description":"Contact Form 7 fires wpcf7_mail_sent after the mail component succeeds, passing only the contact form object. The handler calls WPCF7_Submission::get_instance and get_posted_data to read the submitted values, then maps each form tag name onto an Airtable field name, because Airtable rejects any field it does not recognise. The record is posted to the create-records endpoint with a personal access token. Airtable allows five requests per second per base and answers a breach with 429, after which the caller must wait thirty seconds. Sending inline blocks the visitor and loses the record on failure, so the mapped payload belongs on a queue that retries.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Contact Form 7 to Notion: Create a Page From Each Entry" description: "Send Contact Form 7 entries to Notion: create a page per submission with typed properties, the Notion-Version header, rate limits and the 2,000-char cap." url: "https://wpwebhooks.org/blog/contact-form-7-to-notion/" date: "2026-08-18" --- # Contact Form 7 to Notion: Create a Page From Each Entry **TL;DR:** - Notion has no "append row" call. You create a **page** whose parent is a data source: `POST https://api.notion.com/v1/pages`. - Two headers are mandatory: `Authorization: Bearer` and `Notion-Version`. Omit the version and the request fails. - Flat strings are rejected. Every value must be wrapped in the property type the schema declares — a title becomes an array of rich-text objects. - Limits: about **3 requests per second** per connection, **2,000 characters** per rich-text value, 500KB per request. - A 429 carries a `Retry-After` header in seconds. Read it instead of guessing. / Overview ## How do you send Contact Form 7 entries to Notion? You create one Notion page per submission. Hook `wpcf7_mail_sent`, read the values from `WPCF7_Submission`, convert each one into the property type its Notion column expects, and POST the result to `/v1/pages` with the parent set to your data source. The mental model trips people up more than the code does. In Notion a database row _is_ a page, so "add a row" and "create a page" are the same operation — there is no rows endpoint to look for.¹ / Typing ## Why does Notion reject a flat string? Because Notion stores every cell as a typed structure, not a scalar. Sending `"Ada Lovelace"` where the schema declares a title produces a `validation_error` and no page. The API wants the shape the property type defines, every time, with no coercion — this is the exact opposite of Airtable's `typecast` behaviour, so a mapper written for one will not work for the other. FIG 01 — A form entry becoming a typed Notion page JSON — the same value in three property types ``` // title "Name": { "title": [ { "text": { "content": "Ada Lovelace" } } ] } // email — a plain string, unusually "Email": { "email": "ada@example.com" } // select — the option is created if it does not exist "Source": { "select": { "name": "Contact form" } } // rich text — same array shape as title, 2000 char ceiling "Message": { "rich_text": [ { "text": { "content": "Hello there" } } ] } ``` / The request ## What does the create-page request look like? The parent identifies where the page lands. Current API versions accept `data_source_id` for a database-backed page, and still accept `database_id` and `page_id` parents.¹ Send whichever your integration was set up against, and pin the version header so a future API release cannot change the shape underneath you. PHP — creating the page ``` function my_send_to_notion( array $properties ) { $response = wp_remote_post( 'https://api.notion.com/v1/pages', [ 'timeout' => 15, 'headers' => [ 'Authorization' => 'Bearer ' . MY_NOTION_TOKEN, 'Notion-Version' => '2026-03-11', // required, pin it 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( [ 'parent' => [ 'data_source_id' => MY_NOTION_SOURCE ], 'properties' => $properties, ] ), ] ); if ( is_wp_error( $response ) ) { return $response; } return wp_remote_retrieve_response_code( $response ); } ``` / Mapping ## How do you wrap each value in its property type? Keep the type next to the column name in one table, so the mapper is data rather than a pile of conditionals. Truncate as you go — the 2,000-character ceiling on a rich-text value is a real limit that a long message field will find. PHP — a typed mapper ``` $map = [ // form tag column Notion type 'your-name' => [ 'Name', 'title' ], 'your-email' => [ 'Email', 'email' ], 'your-subject' => [ 'Subject', 'select' ], 'your-message' => [ 'Message', 'rich_text' ], ]; $props = []; foreach ( $map as $tag => list( $column, $type ) ) { if ( empty( $data[ $tag ] ) ) { continue; // never send null — omit the property instead } $v = is_array( $data[ $tag ] ) ? implode( ', ', $data[ $tag ] ) : (string) $data[ $tag ]; $v = mb_substr( $v, 0, 2000 ); // hard API ceiling $props[ $column ] = match ( $type ) { 'title' => [ 'title' => [ [ 'text' => [ 'content' => $v ] ] ] ], 'rich_text' => [ 'rich_text' => [ [ 'text' => [ 'content' => $v ] ] ] ], 'email' => [ 'email' => $v ], 'select' => [ 'select' => [ 'name' => $v ] ], }; } ``` Omitting an empty property is deliberate. Notion treats a missing key as "leave it blank", but an explicit `null` in the wrong position is a validation error — skipping is both safer and shorter. / Limits ## What are Notion's rate and size limits? | Limit | Value | What happens at the boundary | | --- | --- | --- | | Requests per second, per connection | ~3 average, bursts allowed | 429 with a Retry-After header | | Rich text / URL value | 2,000 characters | Request rejected | | Email value | 200 characters | Request rejected | | Blocks per request | 1,000 | Request rejected | | Request payload | 500KB | Request rejected | Notion documents the per-connection limit as "an average of three requests per second, with some bursts beyond the average allowed", and a workspace-wide limit that scales with the plan.² Three per second is 180 a minute — comfortable for form traffic, tight for a bulk import. A 500-entry backfill at one page per request is 500 ÷ 3 ≈ **167 seconds** of continuous traffic, and there is no batch-create endpoint to shorten it. > Notion's rate limit is an average with burst tolerance, not a hard gate — which means a bulk import appears to work for the first few seconds and then starts failing. / Gaps ## What does Notion not protect you from? - **Schema changes break you silently.** Rename a column in the Notion UI and every later request 400s. Nothing warns WordPress, and the person who renamed it has no idea an integration existed. - **No deduplication.** Post the same submission twice and you get two pages. There is no idempotency key — check for an existing page first if double submits are possible. - **System properties are read-only.** `created_time`, `created_by`, `last_edited_time`, `last_edited_by` and `rollup` cannot be set through the API, so a "submitted at" column has to be one you create yourself.¹ - **The integration must be invited.** A valid token still returns "object not found" until the data source is explicitly shared with the integration — the most common first-run failure, and it does not look like a permissions error. ![A courier leaping along a glass catwalk to catch falling glowing form submissions above a neon city, cyberpunk illustration](https://wpwebhooks.org/blog/contact-form-7-to-notion/og_image.jpg) / Delivery ## Should this run inline or on a queue? On a queue, for the same reason as any third-party call inside a form submission: the visitor should never wait on Notion, and a failed request must not be the end of the entry. An inline call inside `wpcf7_mail_sent` has exactly one attempt and keeps the only copy of the data in memory. | Concern | Inline call | Queued delivery | | --- | --- | --- | | Visitor wait | Blocks on Notion | Returns immediately | | Retry-After | Ignored — single attempt | Honoured, entry preserved | | Schema drift | Silent 400, entry gone | Failed attempt kept and visible | | Backfill | Trips the 3/s average | Paced by the queue | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Retries ## How do you handle 429 and Retry-After? Read the header. Notion returns 429 with a `Retry-After` value in seconds, and 529 when the service is overloaded — both are worth retrying, and both tell you when.² A fixed one-second backoff against an average-based limiter tends to make things worse, because you rejoin the traffic exactly when the average is still over. Validation errors are different: a 400 from a property-type mismatch will never succeed on retry. Separate the two classes before you write the loop — the reasoning is the same one behind a [capped exponential backoff](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/), and the same distinction we make in the [Airtable version of this integration](https://wpwebhooks.org/blog/contact-form-7-to-airtable/). /Footnotes ¹ Endpoint, required headers, parent shapes and the read-only system properties: [Notion create a page](https://developers.notion.com/reference/post-page). ² Three requests per second average, 429 with `Retry-After`, and the 2,000-character and 500KB size caps: [Notion request limits](https://developers.notion.com/reference/request-limits). ³ `wpcf7_mail_sent` signature and `get_posted_data()` verified in `includes/submission.php` of [Contact Form 7](https://wordpress.org/plugins/contact-form-7/) 6.1.6. First-party documentation: [contactform7.com/docs](https://contactform7.com/docs/). ⁴ WordPress HTTP helpers: [wp\_remote\_post()](https://developer.wordpress.org/reference/functions/wp_remote_post/), [wp\_json\_encode()](https://developer.wordpress.org/reference/functions/wp_json_encode/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Contact Form 7 to Notion: Create a Page From Each Entry","description":"Send Contact Form 7 entries to Notion: create a page per submission with typed properties, the Notion-Version header, rate limits and the 2,000-char cap.","datePublished":"2026-08-18","dateModified":"2026-08-18","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/contact-form-7-to-notion/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/contact-form-7-to-notion/og_image.jpg","width":1200,"height":630,"caption":"A courier leaping along a glass catwalk to catch falling glowing form submissions above a neon city, cyberpunk illustration"},"keywords":["cf7 notion","contact form 7 notion","contact form 7 to notion","notion api wordpress","wordpress notion integration"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Contact Form 7 to Notion: Create a Page From Each Entry","item":"https://wpwebhooks.org/blog/contact-form-7-to-notion/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do you add a row to a Notion database from WordPress?","acceptedAnswer":{"@type":"Answer","text":"You create a page. In Notion a database row is a page, so the call is POST https://api.notion.com/v1/pages with the parent set to your data source or database ID. There is no separate rows endpoint."}},{"@type":"Question","name":"Why does the Notion API reject my form values?","acceptedAnswer":{"@type":"Answer","text":"Because Notion requires typed property values, not flat strings. A title must be an array of rich-text objects, a select must be an object with a name key, and an email is a plain string. Sending a bare string where the schema declares a title returns a validation_error and creates nothing."}},{"@type":"Question","name":"Is the Notion-Version header required?","acceptedAnswer":{"@type":"Answer","text":"Yes. Every request must carry Notion-Version alongside the Authorization bearer token. Pin a specific version rather than tracking the newest, so a future API release cannot change the request shape underneath a working integration."}},{"@type":"Question","name":"What is the Notion API rate limit?","acceptedAnswer":{"@type":"Answer","text":"An average of about three requests per second per connection, with some bursts allowed, plus a workspace-wide limit that scales with the plan. Exceeding it returns HTTP 429 with a Retry-After header giving the number of seconds to wait."}},{"@type":"Question","name":"Why does Notion say object not found when my token is valid?","acceptedAnswer":{"@type":"Answer","text":"The integration has not been given access to that database. A token is valid workspace-wide but sees only what has been explicitly shared with it, so the data source must be connected to the integration before any page can be created in it."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/contact-form-7-to-notion.png","caption":"FIG 01 — A form entry becoming a typed Notion page","description":"The same wpcf7_mail_sent handler reads the posted data, but Notion will not accept flat strings. Every value must be wrapped in the property type its schema declares: a title becomes an array of rich text objects, an email becomes an email property, a select becomes a named option. The request goes to the create-page endpoint carrying both a bearer token and a Notion-Version header, and names the parent data source. Notion allows an average of three requests per second per connection and returns 429 with a Retry-After header in seconds. Rich text is capped at two thousand characters per value, so long message fields must be truncated or split before sending.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Form to Slack: Post Every Entry to a Channel" description: "Send WordPress form entries to Slack: completion hooks for CF7, Gravity Forms and WPForms, Block Kit payloads, the 1 msg/s limit and why to queue it." url: "https://wpwebhooks.org/blog/wordpress-form-to-slack/" date: "2026-08-15" --- # WordPress Form to Slack: Post Every Entry to a Channel **TL;DR:** - Three form plugins, three completion hooks — `wpcf7_mail_sent`, `gform_after_submission`, `wpforms_process_complete` — all feeding one payload builder. - A Slack incoming webhook is a plain `POST` of JSON. Minimum body is `{ "text": "..." }`; success is HTTP 200 with the body `ok`. - The webhook URL **is** the credential. Slack actively hunts leaked ones and revokes them. - Rate limit is roughly **one message per second** with short bursts tolerated; beyond that you get 429 and a `Retry-After`. - Incoming webhooks cannot edit or delete a message once posted. / Overview ## How do you post WordPress form entries to Slack? Catch the form plugin's completion hook, build a JSON message, and POST it to the incoming webhook URL Slack gave you. There is no authentication step beyond the URL itself and no SDK worth installing — the whole integration is one HTTP request with a `Content-type: application/json` header.¹ What varies between form plugins is only where the data comes from. Everything after that is shared, which is why the payload builder should be one function rather than three copies. FIG 01 — Form entry to Slack channel, and where it blocks / Hooks ## Which hook fires when each form plugin finishes? All three fire after a successful submission, but they hand you very different things — and only one of them gives you the data directly. | Plugin | Hook | What the callback receives | | --- | --- | --- | | Contact Form 7 | wpcf7\_mail\_sent | The WPCF7\_ContactForm object only — read values via WPCF7\_Submission | | Gravity Forms | gform\_after\_submission | $entry and $form — the entry holds values keyed by field ID | | WPForms | wpforms\_process\_complete | $fields, $entry, $form\_data, $entry\_id — four arguments | WPForms is the one with a trap worth knowing: its fourth argument, `$entry_id`, **returns 0 when entry storage is disabled or the site is running WPForms Lite**.² Code that builds a "view this entry" link from it produces a dead link on every Lite install, and it will look fine on your Pro development site. PHP — three sources, one builder ``` // Contact Form 7 add_action( 'wpcf7_mail_sent', function( $contact_form ) { $s = WPCF7_Submission::get_instance(); if ( $s ) { my_notify_slack( $contact_form->title(), $s->get_posted_data() ); } } ); // Gravity Forms add_action( 'gform_after_submission', function( $entry, $form ) { my_notify_slack( $form['title'], $entry ); }, 10, 2 ); // WPForms — note the argument count of 4 add_action( 'wpforms_process_complete', function( $fields, $entry, $form_data, $entry_id ) { my_notify_slack( $form_data['settings']['form_title'], $fields ); }, 10, 4 ); ``` / The request ## What does a Slack incoming webhook expect? A POST with `Content-type: application/json` and a body containing at least a `text` field. Success is HTTP 200 with the literal response body `ok` — not JSON, just those two characters, which is worth knowing because a naive `json_decode()` of the response returns null on a perfectly successful send.¹ PHP — posting to the webhook ``` function my_notify_slack( $form_title, array $values ) { $lines = []; foreach ( $values as $k => $v ) { if ( is_array( $v ) ) { $v = implode( ', ', $v ); } $lines[] = sprintf( '*%s:* %s', $k, wp_strip_all_tags( (string) $v ) ); } $body = [ 'text' => sprintf( "New submission: %s\n%s", $form_title, implode( "\n", $lines ) ), ]; $res = wp_remote_post( MY_SLACK_WEBHOOK_URL, [ 'timeout' => 10, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $body ), ] ); // success is the plain string "ok", not JSON return ! is_wp_error( $res ) && 'ok' === trim( wp_remote_retrieve_body( $res ) ); } ``` Note `wp_strip_all_tags()` on every value. Form input reaches Slack unescaped otherwise, and Slack's `mrkdwn` parser treats `*`, `_` and `<>` as formatting — a message body containing `` renders as a disguised link in your channel. / Formatting ## When should you use Block Kit instead of text? Use `text` until the message needs structure, then move to `blocks`. A plain-text message is one field and always renders; Block Kit gives you headers, two-column field lists and buttons, at the cost of a much larger payload that fails validation as a unit. One practical rule: keep `text` populated even when sending `blocks`. It is what Slack shows in the notification preview and in clients that cannot render the blocks, and a message with blocks but no text arrives as a silent, empty-looking notification. > The webhook URL is the entire authentication story. Anyone who has it can post to your channel as your app, forever, from anywhere. / Limits ## What are Slack's rate limits for incoming webhooks? Incoming webhooks and `chat.postMessage` sit in Slack's Special tier: **one message per second**, with short bursts above that tolerated. Sustained excess returns HTTP 429 with a `Retry-After` header in seconds, and Slack warns that continued abuse risks the app being disconnected or disabled outright.³ One per second sounds ample until a bulk action fans out. Import 500 entries and notify on each and you need 500 seconds — **over eight minutes** — of continuous posting, during which every other notification from the same app is competing for the same budget. Batch the summary instead of sending 500 messages nobody will read. ![Cyberpunk city street where a screen showing the WordPress logo, labelled "form submissions" and "user data", sends a beam of light through a robotic connector into a second screen showing the Slack logo, labelled "Slack channel notifications", under a neon banner reading "Guide: posting WordPress form submissions into a Slack channel".](https://wpwebhooks.org/blog/wordpress-form-to-slack/og_image.jpg) / Gaps ## What does Slack not protect you from? - **A leaked URL is a live credential.** Slack states it actively searches out and revokes leaked webhook URLs — which means a URL committed to a public repository can be revoked without warning, and your notifications simply stop. - **No edit, no delete.** Incoming webhooks cannot modify a message after posting. If a submission contained something that should not have been broadcast, the only remedy is manual. - **No delivery guarantee on your side.** Slack returning `ok` means Slack accepted it. If your request never left because the site was mid-deploy, nothing anywhere records that a notification was owed. - **Everything you send is readable by the whole channel.** A form that collects a phone number or an address will put it in front of every member — including guests. Send a link and a name, not the payload. / Delivery ## Should the send be inline or queued? Queued, once notifications matter. Inline is defensible for a low-traffic contact form: the failure mode is a missed Slack message, not lost data, because the entry is already stored by the form plugin. That is a genuinely different risk profile from the [Airtable](https://wpwebhooks.org/blog/contact-form-7-to-airtable/) and [Notion](https://wpwebhooks.org/blog/contact-form-7-to-notion/) cases, where the third party holds the only copy. It stops being defensible as soon as the message is the record — an alert nobody else stores, or a notification an on-call process depends on. Then a 10-second timeout inside a form submission is both a visitor-facing delay and a single point of loss. | Concern | Inline post | Queued delivery | | --- | --- | --- | | Visitor wait | Up to the full timeout | None | | Slack 429 | Message dropped | Retried after Retry-After | | Bulk actions | Trips the 1/s limit immediately | Paced automatically | | Proof it was sent | None | Per-attempt log with the response | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Secrets ## How do you keep the webhook URL out of your code? Put it in `wp-config.php` as a constant, or in an encrypted store — never in a theme file, never in a plugin you commit, and never in a post meta field that an editor can read. The URL grants posting rights with no expiry and no scope, so it deserves the same handling as a database password. If the site is in version control, add a guard that fails loudly when the constant is missing, rather than defaulting to a hard-coded fallback. A missing-constant fatal on deploy is a five-minute fix; a fallback URL that quietly posts a client's form entries into your own test channel is not. /Footnotes ¹ POST format, the JSON body shape, the plain `ok` success response and the note that Slack revokes leaked webhook URLs: [Sending messages using incoming webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/). ² The four-argument signature and the `$entry_id` returning 0 on WPForms Lite: [wpforms\_process\_complete](https://wpforms.com/developers/wpforms_process_complete/). ³ The Special tier, one message per second with burst tolerance, 429 and `Retry-After`: [Slack Web API rate limits](https://docs.slack.dev/apis/web-api/rate-limits/). ⁴ WordPress HTTP helper used above: [wp\_remote\_post()](https://developer.wordpress.org/reference/functions/wp_remote_post/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Form to Slack: Post Every Entry to a Channel","description":"Send WordPress form entries to Slack: completion hooks for CF7, Gravity Forms and WPForms, Block Kit payloads, the 1 msg/s limit and why to queue it.","datePublished":"2026-08-15","dateModified":"2026-08-15","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-form-to-slack/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-form-to-slack/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk city street where a screen showing the WordPress logo, labelled \"form submissions\" and \"user data\", sends a beam of light through a robotic connector into a second screen showing the Slack logo, labelled \"Slack channel notifications\", under a neon banner reading \"Guide: posting WordPress form submissions into a Slack channel\"."},"keywords":["wordpress form slack","contact form 7 slack","gravity forms slack","wpforms slack","slack incoming webhook wordpress","wordpress slack notification"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Form to Slack: Post Every Entry to a Channel","item":"https://wpwebhooks.org/blog/wordpress-form-to-slack/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do you send a WordPress form submission to Slack?","acceptedAnswer":{"@type":"Answer","text":"Hook the form plugin completion action — wpcf7_mail_sent, gform_after_submission or wpforms_process_complete — build a JSON body containing at least a text field, and POST it to your Slack incoming webhook URL with Content-type application/json."}},{"@type":"Question","name":"What does a Slack incoming webhook return on success?","acceptedAnswer":{"@type":"Answer","text":"HTTP 200 with the plain string ok as the response body. It is not JSON, so decoding the response as JSON returns null even on a completely successful send."}},{"@type":"Question","name":"What is the rate limit for Slack incoming webhooks?","acceptedAnswer":{"@type":"Answer","text":"Roughly one message per second, in Slack Special tier, with short bursts above that tolerated. Sustained excess returns HTTP 429 with a Retry-After header, and Slack warns that continued abuse can get an app disconnected or disabled."}},{"@type":"Question","name":"Should I use text or Block Kit blocks?","acceptedAnswer":{"@type":"Answer","text":"Use text until the message needs structure such as headers, field lists or buttons. Even when sending blocks, keep the text field populated — it is what Slack shows in the notification preview and in clients that cannot render blocks."}},{"@type":"Question","name":"Is a Slack webhook URL a secret?","acceptedAnswer":{"@type":"Answer","text":"Yes. The URL is the entire credential — anyone holding it can post to that channel as your app with no expiry. Store it in wp-config.php or an encrypted store, never in committed code. Slack actively searches for leaked webhook URLs and revokes them."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-form-to-slack.png","caption":"FIG 01 — Form entry to Slack channel, and where it blocks","description":"Each form plugin exposes its own completion hook: wpcf7_mail_sent for Contact Form 7, gform_after_submission for Gravity Forms and wpforms_process_complete for WPForms. All three converge on one payload builder that produces either a plain text message or Block Kit blocks. That JSON is posted to the incoming webhook URL, which is itself the credential and must never be committed or exposed. Slack answers a success with the plain string ok and rate limits incoming webhooks to roughly one message per second, returning 429 with a Retry-After header beyond that. Posting inline ties the visitor to Slack being reachable, so the send belongs behind a queue.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Stripe Webhook: Verify Signatures Correctly" description: "WordPress Stripe webhook guide: verify the Stripe-Signature header against the raw body, survive canonical redirects, and handle duplicate, out-of-order events." url: "https://wpwebhooks.org/blog/stripe-webhook-wordpress/" date: "2026-08-13" --- # WordPress Stripe Webhook: Verify Signatures Correctly **TL;DR:** Stripe signs every webhook, and WordPress has three separate ways of breaking that signature before your code ever runs. - Verify the `Stripe-Signature` header against the **raw** request body — `$request->get_body()`, never the parsed array. - `signed_payload` is `t + "." + raw_body`, HMAC-SHA256 with the endpoint secret, compared in constant time. - Stripe counts any **3xx as a failed delivery**. A trailing-slash redirect in front of your route silently kills every event. - The official libraries allow a **5-minute** timestamp tolerance. Never set it to `0` — that disables the recency check entirely. - Delivery is at-least-once and **unordered**. Deduplicate on the event ID and never assume the sequence. / Overview ## What does Stripe actually **send** to your endpoint? A single `POST` with a JSON [Event object](https://docs.stripe.com/api/events) as the body, and a `Stripe-Signature` header carrying a timestamp and one or more signatures. That header is the entire security model. Without checking it, your endpoint is a public URL that anyone who guesses it can use to mark orders as paid. The header is a comma-separated list of prefix/value pairs on a single line: HTTP — the signature header, split for readability ``` Stripe-Signature: t=1492774577, v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd, v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39 ``` `t` is the Unix timestamp of the delivery attempt. `v1` is the live signature scheme. `v0` appears only on test events and exists purely to help you test — Stripe's own guidance is to **ignore every scheme that is not `v1`**, because accepting `v0` is a downgrade attack waiting to happen. You can also see more than one `v1` value at once: when you roll an endpoint secret you can keep the old one alive for up to 24 hours, and Stripe signs with both during that window. FIG 01 — Where a Stripe webhook survives or dies inside WordPress / Raw body ## Why does WordPress **break** Stripe signature verification? Because the signature is computed over the exact bytes Stripe sent, and almost every convenient way of reading a request body in WordPress hands you something else. Stripe's documentation is blunt about it: any manipulation of the raw body causes verification to fail. Inside a REST route, `$request->get_json_params()` gives you a decoded array. Re-encoding that array produces different bytes — different key order, different whitespace, different unicode escaping — and the HMAC will not match. The same goes for `$request->get_params()`. The only correct source is the raw body: PHP — reading the body correctly ``` $payload = $request->get_body(); // correct: untouched bytes $sig = $request->get_header( 'stripe_signature' ); // WRONG — all of these change the bytes: // $payload = wp_json_encode( $request->get_json_params() ); // $payload = wp_json_encode( $request->get_params() ); ``` Note the header name. `WP_REST_Request::get_header()` normalises header names by lowercasing them and converting dashes to underscores, so `Stripe-Signature` is looked up as `stripe_signature`. Passing the literal header name returns `null`, and a `null` signature is indistinguishable from an attack in most people's error handling. / Routing ## How do you register a route WordPress will not **redirect**? This is the failure nobody debugs, because nothing appears in your logs at all. Stripe's own delivery status table lists `302` — and any other `3xx` — as an error, with the fix being to point the endpoint at the URL the redirect resolves to. WordPress produces those redirects enthusiastically: `redirect_canonical()` will bounce a request between trailing-slash variants, and a host-level rule will bounce `http` to `https` or `www` to bare. So the URL you paste into Stripe has to be the final one, byte for byte. Register the route, then confirm what a raw `POST` to it actually returns: PHP — the route registration ``` add_action( 'rest_api_init', function () { register_rest_route( 'your-plugin/v1', '/stripe', [ 'methods' => 'POST', 'callback' => 'your_plugin_stripe_webhook', // The signature IS the authentication. Anything else would // reject Stripe, which sends no cookies and no nonce. 'permission_callback' => '__return_true', ] ); } ); ``` Returning `__return_true` from a `permission_callback` is normally a red flag, and on a route that does anything before verifying a signature it still is. It is acceptable here only because the very first statement in the callback is the HMAC check and the route does nothing else. Verify the deployed URL before you trust it: Shell — confirm no redirect sits in front of the route ``` curl -si -X POST https://example.com/wp-json/your-plugin/v1/stripe -H 'Content-Type: application/json' -d '{}' | head -n 1 # want: HTTP/2 400 (route reached, signature rejected) # bad: HTTP/2 301 (Stripe will mark every delivery failed) ``` / Verification ## How do you verify the **Stripe-Signature** header? Four steps, and each one has a way to get it subtly wrong. Split the header into its `t` and `v1` parts, build `signed_payload` by concatenating the timestamp, a literal `.`, and the raw body, compute an HMAC-SHA256 over it keyed with the endpoint secret, then compare in constant time. PHP — manual verification, no SDK required ``` function your_plugin_verify_stripe( $payload, $header, $secret, $tolerance = 300 ) { $timestamp = null; $signatures = []; foreach ( explode( ',', (string) $header ) as $part ) { $pair = explode( '=', trim( $part ), 2 ); if ( 2 !== count( $pair ) ) { continue; } if ( 't' === $pair[0] ) { $timestamp = (int) $pair[1]; } elseif ( 'v1' === $pair[0] ) { // v1 only. Ignoring v0 prevents a downgrade attack. $signatures[] = $pair[1]; } } if ( ! $timestamp || ! $signatures ) { return false; } if ( abs( time() - $timestamp ) > $tolerance ) { return false; // replayed, or the server clock has drifted } $expected = hash_hmac( 'sha256', $timestamp . '.' . $payload, $secret ); foreach ( $signatures as $candidate ) { // hash_equals, never ===, to defeat timing analysis if ( hash_equals( $expected, $candidate ) ) { return true; } } return false; } ``` The loop over `$signatures` is not decoration — it is what keeps deliveries flowing through a secret roll, when Stripe sends one `v1` per active secret. Code that reads only the first `v1` works fine until the day you rotate, then drops half your events for 24 hours. > Without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records. — Stripe webhook documentation / Tolerance ## What does the **5-minute tolerance** actually protect? Replay. The signature proves the payload came from Stripe; it does not prove it came from Stripe _just now_. Anyone who captures one valid request — from a proxy log, an error report, a misconfigured APM tool — holds a payload and a signature that stay mathematically valid forever. The timestamp is inside the signed string, so it cannot be edited, but it can be re-sent. Stripe's official libraries default to a 300-second window, and the docs carry an explicit warning against setting it to `0`, because zero does not mean "strictest" — it disables the recency check completely. The real operational cost of the check is clock drift: your PHP server compares `time()` against Stripe's timestamp, so a host whose clock is four minutes slow will reject roughly half of all live traffic with a signature error that looks like a secret mismatch. Run NTP. | Behaviour | Value | Consequence | | --- | --- | --- | | Signature tolerance (libraries) | 300 seconds | Server clock drift reads as an auth failure | | Automatic retries, live mode | Up to 3 days, exponential backoff | A 4-hour outage self-heals; a 4-day one does not | | Automatic retries, sandbox | 3 attempts over a few hours | Test behaviour is not production behaviour | | Manual resend, Dashboard | 15 days after event creation | Your window to replay by hand | | Manual resend, CLI | 30 days after event creation | stripe events resend | | Endpoints per account | 16 | Not a per-plugin resource — budget it | | Redirect responses | Any 3xx counts as failed | Register the resolved URL | | TLS | v1.2 or v1.3 only | An old terminator drops every delivery | / Response ## Why must you return **2xx** before doing the work? Because Stripe's timeout is not your business logic's timeout, and the two are in direct conflict. Stripe's instruction is to return a `2xx` _prior to_ any complex logic that might cause a timeout — its own example being that you must return `200` before marking an invoice paid in an accounting system. The reason is compounding. If your handler synchronously calls a CRM, a fulfilment API and an email service, its latency is the sum of three third parties. Any of them being slow turns into a timed-out delivery, which turns into a retry, which arrives while the first attempt is still running — and now you are executing the same fulfilment twice, concurrently. The retry system designed to protect you becomes the thing that double-ships the order. | Concern | Inline handler | Verify then queue | | --- | --- | --- | | Response time | Sum of every downstream call | One insert, milliseconds | | Slow third party | Stripe times out, marks it failed | Already answered 200 | | Retry arrives | Runs concurrently with attempt one | Rejected by the event ID index | | Traffic spike | Every renewal hits at once | Drained at a rate you control | | Failure visibility | Only in Stripe’s delivery log | Per-attempt record on your side | Stripe makes the same recommendation directly — configure the handler to process incoming events with an asynchronous queue, because a spike such as the start of the month when every subscription renews will otherwise overwhelm the endpoint host. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Duplicates ## How do you handle **duplicate and out-of-order** events? By assuming both, always. Stripe states plainly that it does not guarantee delivery in the order events were generated, and that endpoints might occasionally receive the same event more than once. Creating a subscription can emit `customer.subscription.created`, `invoice.created`, `invoice.paid` and `charge.created` — and they can land in any sequence. The deduplication key is the event ID, and the storage needs to be something with a real uniqueness constraint. A WordPress option or transient is not that: PHP — idempotent intake ``` global $wpdb; // Unique index on event_id makes the race impossible, not unlikely. $inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->prefix}your_plugin_stripe_events (event_id, type, received_at) VALUES (%s, %s, %d)", $event['id'], $event['type'], time() ) ); if ( 0 === $inserted ) { // Already seen. Acknowledge and stop. return new WP_REST_Response( [ 'duplicate' => true ], 200 ); } ``` Ordering needs a different answer, because no key can fix it: never derive state from the sequence of arrivals. When the current shape of an object matters, re-read it from the API instead of reconstructing it from the events you happen to have received. Stripe suggests exactly that — retrieve the invoice, charge or subscription with the information from the event you did get. There is a second, subtler duplicate case worth knowing about. Stripe notes that in some cases two separate `Event` objects are generated for the same underlying change, with different IDs. An event-ID index will not catch those; the documented identifier is the ID of the object in `data.object` combined with `event.type`. / Limits ## What does Stripe **not** protect you from? Four things, and all four are yours to build. 1. **An unbounded queue.** Stripe retries for up to three days with backoff. If your worker is broken for two of those days, the events all arrive when it recovers — a burst, not a trickle. Bound your queue and shed or defer rather than fall over. 2. **Business-level replay.** The 5-minute tolerance stops network replay. It does nothing about a legitimate, correctly signed retry arriving after your handler already granted access. Only idempotency at the point of effect fixes that. 3. **Endpoint discovery.** Signature verification is authentication, not rate limiting. An attacker who finds the URL can still post garbage at it as fast as your host will accept it, and every request costs you a PHP worker and an HMAC. Stripe publishes its [outbound IP ranges](https://docs.stripe.com/ips) and recommends allowlisting them at the firewall _in addition to_ checking the signature. 4. **Your own secret handling.** The endpoint secret is a shared key with the same power as the signature it validates. It belongs in `wp-config.php` or the environment — never in the database where an SQL injection or a careless export reaches it, and never in a file under the webroot. **Do not skip verification in a sandbox.** Test-mode endpoints get their own secret, and Stripe sends a fake `v0` signature alongside the real `v1`. Code that "works in test" because it accepted `v0`, or skipped the check when `WP_DEBUG` was on, ships that behaviour to production unnoticed. If you need the same discipline for events leaving WordPress rather than arriving, the failure modes are close cousins — see [retry policy and exponential backoff](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/) and [why WordPress webhooks fail silently in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/). /Footnotes ¹ Header format, `signed_payload` construction, the `v0`/`v1` scheme rule and the 24-hour secret-roll overlap from Stripe's [webhooks documentation](https://docs.stripe.com/webhooks). ² Retry windows, the 3xx-is-a-failure rule, the 16-endpoint limit, TLS requirements and the ordering and duplicate guarantees from the same page's event delivery behaviours and best practices sections. ³ Outbound IP ranges for firewall allowlisting are published at [docs.stripe.com/ips](https://docs.stripe.com/ips). ⁴ Signature failure triage, including the raw-body requirement, in Stripe's [signature verification guide](https://docs.stripe.com/webhooks/signature). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Stripe Webhook: Verify Signatures Correctly","description":"WordPress Stripe webhook guide: verify the Stripe-Signature header against the raw body, survive canonical redirects, and handle duplicate, out-of-order events.","datePublished":"2026-08-13","dateModified":"2026-08-13","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/stripe-webhook-wordpress/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/og_image.jpg","width":1200,"height":630,"caption":"WordPress Stripe Webhook: Verify Signatures Correctly"},"keywords":["wordpress stripe webhook","stripe webhook signature verification","stripe signature header wordpress","verify stripe webhook php","stripe webhook rest api wordpress","stripe webhook idempotency"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Stripe Webhook: Verify Signatures Correctly","item":"https://wpwebhooks.org/blog/stripe-webhook-wordpress/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why does my Stripe webhook signature verification fail in WordPress?","acceptedAnswer":{"@type":"Answer","text":"Almost always because the code hashes a re-encoded body instead of the raw one. Stripe signs the exact bytes it sent, so use $request->get_body() inside a REST route and never wp_json_encode() of get_json_params(). The second most common cause is server clock drift pushing the timestamp outside the 5-minute tolerance."}},{"@type":"Question","name":"What header does Stripe use to sign webhooks?","acceptedAnswer":{"@type":"Answer","text":"Stripe-Signature. It contains a t= timestamp and one or more scheme-prefixed signatures. Only v1 is a live scheme; v0 appears on test events and should be ignored to prevent a downgrade attack. Inside WP_REST_Request::get_header() the name is normalised to stripe_signature."}},{"@type":"Question","name":"Why is Stripe marking my webhook deliveries as failed when the endpoint works?","acceptedAnswer":{"@type":"Answer","text":"Stripe counts any 3xx response as a failed delivery. WordPress canonical redirects between trailing-slash variants, and host-level http-to-https or www rules, are the usual cause. Register the fully resolved URL and confirm with curl that a POST returns a 2xx or 4xx, never a 301 or 302."}},{"@type":"Question","name":"How long does Stripe retry a failed webhook?","acceptedAnswer":{"@type":"Answer","text":"Up to three days with exponential backoff in live mode. Sandbox events are retried three times over a few hours. You can also resend manually for 15 days from the Dashboard or 30 days with the Stripe CLI."}},{"@type":"Question","name":"Are Stripe webhook events delivered in order?","acceptedAnswer":{"@type":"Answer","text":"No. Stripe explicitly does not guarantee ordering, and endpoints can receive the same event more than once. Deduplicate on the event ID with a unique database index, and re-read objects from the API rather than reconstructing state from the sequence of events you received."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/stripe-webhook-wordpress.png","caption":"FIG 01 — Where a Stripe webhook survives or dies inside WordPress","description":"Stripe posts a signed JSON event to a registered REST route. Two WordPress behaviours can kill the delivery before any code runs: a canonical redirect answers with a 3xx, which Stripe counts as a failure, and any code that reads the parsed body instead of the raw body breaks the signature. A surviving request has its Stripe-Signature header split into a timestamp and a v1 signature, an HMAC-SHA256 of timestamp dot raw body is computed with the endpoint secret and compared in constant time, and the timestamp is checked against a five minute tolerance. Only then does the handler return 200 and hand the event to a queue, deduplicating on the event id.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "post_submitbox_misc_actions: Docs & Block Editor Fix" description: "post_submitbox_misc_actions documentation: the exact signature, why the hook never fires in the block editor, and the PluginPostStatusInfo equivalent that does." url: "https://wpwebhooks.org/blog/post-submitbox-misc-actions/" date: "2026-08-10" --- # post_submitbox_misc_actions: Docs & Block Editor Fix **TL;DR:** `post_submitbox_misc_actions` still works exactly as documented — it just never fires on the editor most sites now use. - Signature: `do_action( 'post_submitbox_misc_actions', $post )`. Added in WordPress 2.9.0; the `$post` parameter arrived in 4.4.0. - It fires inside `post_submit_meta_box()`, after the post date setting, in `wp-admin/includes/meta-boxes.php`. - The Publish box is registered with `__back_compat_meta_box => true`, and the block editor **skips every box carrying that flag** — so the hook never runs there. - There is no error and no notice. Your callback simply never executes. - The block editor equivalent is a `PluginPostStatusInfo` slot registered in JavaScript. / Overview ## When does **post\_submitbox\_misc\_actions** fire? When WordPress renders the classic Publish meta box, immediately after the post date row and before the box closes. It is the standard extension point for adding your own control — a checkbox, a select, a status line — into the panel that already holds Status, Visibility and Publish immediately. The declaration in [meta-boxes.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-admin/includes/meta-boxes.php) is short, and the version tags matter: PHP — wp-admin/includes/meta-boxes.php ``` /** * Fires after the post time/date setting in the Publish meta box. * * @since 2.9.0 * @since 4.4.0 Added the $post parameter. * * @param WP_Post $post WP_Post object for the current post. */ do_action( 'post_submitbox_misc_actions', $post ); ``` One parameter, a `WP_Post`. Code written before WordPress 4.4 calls `get_post()` inside the callback instead, which still works and is why so much of the sample code you will find online ignores the argument entirely. / The trap ## Why does it do **nothing** in the block editor? Because the box it lives in is never rendered there, and the mechanism that removes it is deliberate rather than accidental. WordPress registers the Publish box like this: PHP — how submitdiv is registered ``` $publish_callback_args = array( '__back_compat_meta_box' => true ); add_meta_box( 'submitdiv', __( 'Publish' ), 'post_submit_meta_box', null, 'side', 'core', $publish_callback_args ); ``` And `do_meta_boxes()` reads that flag when it walks the registered boxes: PHP — wp-admin/includes/template.php ``` // If a meta box is just here for back compat, don't show it in the block editor. if ( $screen->is_block_editor() && isset( $box['args']['__back_compat_meta_box'] ) && $box['args']['__back_compat_meta_box'] ) { continue; } ``` That `continue` is the whole story. The box is skipped, so `post_submit_meta_box()` is never called, so `do_action( 'post_submitbox_misc_actions', $post )` is never reached. Your `add_action()` registered successfully. The hook exists. It simply has no caller on that screen. > There is no deprecation notice, no admin warning and no PHP error. The only symptom is a control that used to be there and now is not — which is exactly the class of bug that survives a code review. FIG 01 — Why the same hook fires on one editor and not the other / Family ## What are the **sibling** publish-box hooks? Four, and picking the right one saves a lot of CSS. They all live in the same file and all share the block-editor fate above. | Hook | Since | Renders | Parameter | | --- | --- | --- | --- | | post\_submitbox\_start | 2.7.0 | Top of the publishing-actions row, next to Move to Trash | $post — or null on the Edit Link screen | | post\_submitbox\_minor\_actions | 4.4.0 | After Save Draft and Preview | $post | | post\_submitbox\_misc\_actions | 2.9.0 | After the date setting, inside the misc section | $post (since 4.4.0) | | attachment\_submitbox\_misc\_actions | 3.5.0 | The attachment edit screen’s Save box | $post | Two details in that table catch people out. `post_submitbox_start` is documented as `WP_Post|null` because the Edit Link screen fires it with `null` — a callback that types the parameter as `WP_Post` or calls a method on it will fatal there. And attachments never reach `post_submitbox_misc_actions` at all: they render `attachment_submit_meta_box()` instead, which fires its own `attachment_submitbox_misc_actions`. / Rendering ## How do you **render** a field in the Publish box? Echo the markup, wrapped in the class WordPress already styles, and include a nonce. The `misc-pub-section` class gives you the same padding and separator as the built-in rows, so the control does not look bolted on: PHP — a control in the Publish box ``` add_action( 'post_submitbox_misc_actions', function ( $post ) { if ( ! in_array( $post->post_type, [ 'post', 'product' ], true ) ) { return; } if ( ! current_user_can( 'edit_post', $post->ID ) ) { return; } $notify = '1' === get_post_meta( $post->ID, '_yp_notify', true ); wp_nonce_field( 'yp_notify_save', 'yp_notify_nonce' ); ?>
{ const [ meta, setMeta ] = useEntityProp( 'postType', 'post', 'meta' ); return ( setMeta( { ...meta, _yp_notify: value } ) } /> ); }; registerPlugin( 'your-plugin-notify', { render: NotifyToggle } ); ``` For `useEntityProp` to see the value, the meta key must be registered with `show_in_rest` — and a key beginning with an underscore is protected, so it also needs an `auth_callback`: PHP — exposing the meta to both editors ``` register_post_meta( 'post', '_yp_notify', [ 'type' => 'boolean', 'single' => true, 'show_in_rest' => true, 'auth_callback' => function ( $allowed, $meta_key, $post_id ) { return current_user_can( 'edit_post', $post_id ); }, ] ); ``` / Both ## How do you support **both editors** from one plugin? Register both paths and let the screen decide, rather than trying to detect the editor and branch. The PHP hook is inert on block-editor screens and the JS plugin is never loaded on classic ones, so the two never collide. 1. **Register the meta once**, with `show_in_rest` and an `auth_callback`, so both editors read and write the same key. 2. **Keep the PHP hook** for the classic editor, the Quick Edit path, and any post type that opted out of the block editor via `use_block_editor_for_post_type`. 3. **Enqueue the JS on `enqueue_block_editor_assets`** so it only loads where the slot exists. 4. **Do not duplicate the write.** The block editor saves meta through the REST API, not through `save_post`'s `$_POST` — the nonce guard above already makes that a no-op, which is the point of it. **Check before you assume classic is gone.** A post type registered with `'show_in_rest' => false` still uses the classic editor, as does any site running the Classic Editor plugin. Dropping the PHP hook because "everything is Gutenberg now" removes the control for exactly the installs most likely to depend on it. /Footnotes ¹ Hook position, `@since` tags and the `WP_Post|null` signature of `post_submitbox_start` verified in [wp-admin/includes/meta-boxes.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-admin/includes/meta-boxes.php) against WordPress 7.0.2. ² The `__back_compat_meta_box` skip is in `do_meta_boxes()` in [wp-admin/includes/template.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-admin/includes/template.php). ³ Slot reference for the Summary panel: [PluginPostStatusInfo](https://developer.wordpress.org/block-editor/reference-guides/slotfills/plugin-post-status-info/). ⁴ Protected meta keys and `auth_callback` behaviour: [register\_post\_meta()](https://developer.wordpress.org/reference/functions/register_post_meta/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"post_submitbox_misc_actions: Docs & Block Editor Fix","description":"post_submitbox_misc_actions documentation: the exact signature, why the hook never fires in the block editor, and the PluginPostStatusInfo equivalent that does.","datePublished":"2026-08-10","dateModified":"2026-08-10","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/post-submitbox-misc-actions/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/og_image.jpg","width":1200,"height":630,"caption":"post_submitbox_misc_actions: Docs & Block Editor Fix"},"keywords":["post submitbox misc actions","post submitbox misc actions hook","publish meta box hook wordpress","post submitbox start","plugin post status info","back compat meta box block editor"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"post_submitbox_misc_actions: Docs & Block Editor Fix","item":"https://wpwebhooks.org/blog/post-submitbox-misc-actions/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is post_submitbox_misc_actions?","acceptedAnswer":{"@type":"Answer","text":"A WordPress action that fires inside the classic Publish meta box, immediately after the post date setting. It was added in WordPress 2.9.0 and gained its WP_Post parameter in 4.4.0. It is the standard place to render an extra control alongside Status, Visibility and Publish immediately."}},{"@type":"Question","name":"Why does post_submitbox_misc_actions not work in Gutenberg?","acceptedAnswer":{"@type":"Answer","text":"The Publish meta box is registered with the __back_compat_meta_box argument set to true, and do_meta_boxes() skips every box carrying that flag when the screen is the block editor. post_submit_meta_box() is therefore never called and the action never fires. There is no error or deprecation notice."}},{"@type":"Question","name":"What is the block editor equivalent of post_submitbox_misc_actions?","acceptedAnswer":{"@type":"Answer","text":"The PluginPostStatusInfo slot from @wordpress/editor, registered with registerPlugin(). It renders into the Summary panel. The post meta it edits must be registered with show_in_rest, plus an auth_callback if the key begins with an underscore."}},{"@type":"Question","name":"How do I save a field added with post_submitbox_misc_actions?","acceptedAnswer":{"@type":"Answer","text":"On save_post, after checking wp_is_post_autosave() and wp_is_post_revision(), verifying a nonce, and confirming current_user_can(\"edit_post\"). Check that your nonce field is present in $_POST before treating a missing value as unchecked, otherwise autosaves and REST saves will wipe the meta."}},{"@type":"Question","name":"What is the difference between post_submitbox_start and post_submitbox_misc_actions?","acceptedAnswer":{"@type":"Answer","text":"post_submitbox_start fires at the top of the publishing-actions row next to Move to Trash and dates from 2.7.0; post_submitbox_misc_actions fires lower down in the misc section after the date setting. post_submitbox_start can receive null instead of a WP_Post on the Edit Link screen, so callbacks must handle that."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/post-submitbox-misc-actions.png","caption":"FIG 01 — Why the same hook fires on one editor and not the other","description":"Both editors register the Publish meta box under the id submitdiv, but it is registered with the __back_compat_meta_box argument set to true. When the screen is the block editor, do_meta_boxes skips every box carrying that flag, so post_submit_meta_box is never called and post_submitbox_misc_actions never fires. On the classic editor the same box renders normally and the hook fires after the post date setting. Attachments take a third path and fire attachment_submitbox_misc_actions instead. The block editor equivalent is a PluginPostStatusInfo slot rendered in JavaScript.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WooCommerce SMS Order Notification: Build It on Twilio" description: "Build WooCommerce SMS order notifications on Twilio: the right status hook, E.164 phone normalisation, per-segment cost maths, and signed delivery callbacks." url: "https://wpwebhooks.org/blog/woocommerce-sms-order-notification-twilio/" date: "2026-08-07" --- # WooCommerce SMS Order Notification: Build It on Twilio **TL;DR:** The Twilio call is the easy part. WooCommerce hands you a phone number Twilio will reject, on a hook that may never fire. - `woocommerce_order_status_changed` **does not fire on a brand new order** — it is skipped when there is no previous status. - `WC_Validation::is_phone()` only checks the allowed characters. It never normalises to E.164, so `(555) 123-4567` passes checkout and fails at Twilio with error `21211`. - Twilio's response status is `queued`, not delivered. The real outcome arrives later on a `StatusCallback` signed with `X-Twilio-Signature`. - One emoji drops the segment limit from 160 GSM-7 characters to 70 UCS-2 — and multiplies the bill. - US traffic from an unregistered 10DLC number is blocked with error `30034`, no matter how correct your code is. / Hook choice ## Which WooCommerce hook should **fire** the SMS? The per-status hook, `woocommerce_order_status_{to}`, not the generic one — and the reason is a branch in `WC_Order` that costs people their first-order notifications. Both hooks live in the same status-transition block in [class-wc-order.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/class-wc-order.php), but they are not siblings. The per-status action fires unconditionally; the generic one fires inside an `if`: PHP — class-wc-order.php, status\_transition() ``` // Always fires, including the very first status a new order gets. do_action( 'woocommerce_order_status_' . $status_transition['to'], $this->get_id(), $this, $status_transition ); // ... status note added ... if ( ! empty( $status_transition['from'] ) ) { do_action( 'woocommerce_order_status_' . $status_transition['from'] . '_to_' . $status_transition['to'], $this->get_id(), $this ); // Only reached when a PREVIOUS status existed. do_action( 'woocommerce_order_status_changed', $this->get_id(), $status_transition['from'], $status_transition['to'], $this ); } ``` An order created straight into `processing` by a gateway that captures immediately has an empty `from`. The per-status hook fires; `woocommerce_order_status_changed` does not. The bug this produces is nasty because it is partial: manual admin status changes work perfectly, so the feature tests fine and then misses a slice of real orders. If you do bind the generic hook — for a state machine that genuinely needs the previous status — remember it passes four arguments and `add_action()` defaults to one: PHP — the argument-count trap ``` // $from and $to are silently missing without the trailing 4. add_action( 'woocommerce_order_status_changed', 'yp_order_changed', 10, 4 ); function yp_order_changed( $order_id, $from, $to, $order ) { /* ... */ } ``` FIG 01 — From order transition to a billed SMS segment / Phone numbers ## Why is the **billing phone** not safe to send? Because WooCommerce never promised it was a phone number. This is the entire validator: PHP — WC\_Validation::is\_phone() ``` public static function is_phone( $phone ) { if ( 0 < strlen( trim( preg_replace( '/[s#0-9_-+/().]/', '', $phone ) ) ) ) { return false; } return true; } ``` Strip out digits, spaces and a handful of punctuation marks; if anything is left, reject. That is a character-class check, nothing more. It validates no country code, no length, no plausibility. Every one of these passes WooCommerce checkout and every one of them is rejected by Twilio with `21211 — Invalid 'To' Phone Number`: | Customer typed | is\_phone() | Twilio | | --- | --- | --- | | (555) 123-4567 | passes | rejected — no country code | | 07700 900123 | passes | rejected — national format | | +1 555 123 4567 | passes | accepted once spaces are stripped | | 555.123.4567 ext 12 | rejected — "ext" is not in the class | — | | 00 44 7700 900123 | passes | rejected — 00 is not + | | \- - - | passes | rejected — no digits at all | Worse, the field is not necessarily required. The checkout field definition sets `'required' => 'required' === CartCheckoutUtils::get_phone_field_visibility()`, so on a store configured with phone optional you will be handed an empty string for a meaningful share of orders. The fix is to normalise before you queue, and to treat a number you cannot normalise as a skipped notification rather than a failed order: PHP — normalise, or decline to send ``` function yp_to_e164( $raw, $country ) { $digits = preg_replace( '/[^0-9+]/', '', (string) $raw ); if ( '' === $digits ) { return null; } if ( 0 === strpos( $digits, '+' ) ) { return $digits; // already E.164 } if ( 0 === strpos( $digits, '00' ) ) { return '+' . substr( $digits, 2 ); // 00 44 ... -> +44 ... } // A national number. Guessing the country from the billing address is // a heuristic, not a rule — use a real E.164 library in production. $prefixes = [ 'US' => '+1', 'GB' => '+44', 'PL' => '+48', 'DE' => '+49' ]; if ( ! isset( $prefixes[ $country ] ) ) { return null; } return $prefixes[ $country ] . ltrim( $digits, '0' ); } ``` > A phone field that validates punctuation is not a phone field. Treat the billing phone as untrusted free text and the rest of the integration gets simpler. / The call ## How do you **call** the Twilio Messages API? One `POST`, form-encoded, with HTTP Basic auth. No SDK is required — `wp_remote_post()` covers it, which keeps the dependency footprint at zero: PHP — sending one message ``` function yp_send_sms( $to, $body ) { $sid = defined( 'YP_TWILIO_SID' ) ? YP_TWILIO_SID : ''; $token = defined( 'YP_TWILIO_TOKEN' ) ? YP_TWILIO_TOKEN : ''; $response = wp_remote_post( "https://api.twilio.com/2010-04-01/Accounts/{$sid}/Messages.json", [ 'timeout' => 15, 'headers' => [ 'Authorization' => 'Basic ' . base64_encode( "{$sid}:{$token}" ), ], 'body' => [ 'To' => $to, 'MessagingServiceSid' => YP_TWILIO_SERVICE_SID, 'Body' => $body, 'StatusCallback' => rest_url( 'your-plugin/v1/sms-status' ), // Do not let a message sit in the queue for 10 hours. 'ValidityPeriod' => 600, ], ] ); if ( is_wp_error( $response ) ) { return $response; // transport failure — retryable } $code = wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 201 !== $code ) { // $data['code'] carries the Twilio error number, e.g. 21211. return new WP_Error( 'twilio', $data['message'] ?? '', $data ); } return $data['sid']; // status here is "queued", not "delivered" } ``` Two parameters deserve more attention than they usually get. `MessagingServiceSid` and `From` are mutually exclusive, and using the service SID is what lets Twilio pick a sender from a pool — which is also where 10DLC registration is attached. `ValidityPeriod` defaults to **36,000 seconds**, ten hours; for an order notification, a message that arrives ten hours late is worse than one that never arrives, so cap it. / Cost ## What does an order SMS actually **cost**? More than the per-message rate, because you are not billed per message — you are billed per _segment_. Twilio's limit is 160 GSM-7 characters per segment, or **70 UCS-2 characters** once any character falls outside GSM-7, with a hard ceiling of 1,600 characters per message. Take a plain notification at 150 characters. That is one segment. Now add a single emoji to make it friendlier: - Plain, 150 chars, GSM-7 → 150 ÷ 160 = **1 segment**. - Same text plus one emoji → the whole message becomes UCS-2 → 151 ÷ 70 = 2.16 → **3 segments**. - At a US rate of $0.0079 per segment: $0.0079 versus 3 × $0.0079 = **$0.0237**. Scale that to volume and the emoji is a line item. At 4,000 orders a month: 4,000 × $0.0079 = **$31.60** plain, against 4,000 × $0.0237 = **$94.80** with the emoji — $63.20 a month, $758.40 a year, for one character. Add the order number and a tracking URL and a "short" message routinely lands at 3 segments before anyone notices. The practical control is to measure the body before you send it and to keep URLs out of the concatenation where you can. Note also that concatenated segments use 153 GSM-7 or 67 UCS-2 characters each, since the header that reassembles them consumes part of every segment — so the arithmetic above is the optimistic version. ![Cyberpunk illustration titled "Sending WooCommerce Order SMS Through Twilio Programmable Messaging": a neon server rack badged with the Woo logo and "ORD-7359 (status: processing)" streams a packet trail into a "Twilio — Programmable Messaging" panel showing API, gear and SMS nodes, which forwards to a phone displaying an SMS bubble.](https://wpwebhooks.org/blog/woocommerce-sms-order-notification-twilio/og_image.jpg) / Delivery ## Should the send be **inline or queued**? Queued, and the argument is not the usual hand-waving about performance. It is that `woocommerce_order_status_changed` fires inside the request that is processing the order — often the customer's checkout request, often a gateway's IPN callback. A 15-second timeout on an HTTP call to Twilio is 15 seconds added to that request in the worst case. If it is the checkout, the buyer is looking at a spinner. If it is the gateway callback, the gateway may time out and retry, and now the order transitions again and you send a second SMS. The failure mode is not a slow page — it is a duplicate billed message caused by someone else's retry logic. | Concern | Inline wp\_remote\_post | Queued send | | --- | --- | --- | | Checkout latency | Up to the full HTTP timeout | One row written, then return | | Twilio 500 or timeout | Notification lost, no record | Retried with backoff | | Gateway retries the callback | Second SMS, billed again | Deduplicated on order + status | | Flash sale burst | Every order opens a socket | Drained at a controlled rate | | Audit trail | Twilio console only | Per-attempt log on your side | WordPress already ships the queue: Action Scheduler comes bundled with WooCommerce, so `as_enqueue_async_action()` is available on any store without adding a dependency. If you are new to it, [how Action Scheduler works](https://wpwebhooks.org/blog/action-scheduler-wordpress/) and [building a job queue in WordPress](https://wpwebhooks.org/blog/wordpress-job-queue/) cover the mechanics. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Callbacks ## How do you **track delivery** with a status callback? Twilio's `201` means accepted, not delivered — the `status` field in that response is `queued`. Of the full set of values (`accepted`, `scheduled`, `queued`, `sending`, `sent`, `delivered`, `undelivered`, `failed`, `canceled`, and the inbound ones), only `delivered`, `undelivered`, `failed` and `canceled` are terminal. Everything before that is in flight. The `StatusCallback` URL receives those transitions — and it is a public endpoint receiving claims about your billing, so it must be verified. Twilio signs with `X-Twilio-Signature`, an **HMAC-SHA1** keyed with your auth token, over the full webhook URL with the POST parameters sorted alphabetically and appended: PHP — validating the status callback ``` function yp_valid_twilio_sig( $url, $params, $signature, $token ) { ksort( $params ); $data = $url; foreach ( $params as $key => $value ) { $data .= $key . $value; } $expected = base64_encode( hash_hmac( 'sha1', $data, $token, true ) ); return hash_equals( $expected, (string) $signature ); } ``` The URL has to be the exact one Twilio called, including the query string and its original percent-encoding. Twilio's own guidance is explicit: never pull query parameters out and pass them separately, and never decode or re-encode the URL — either breaks validation. Behind a reverse proxy this bites, because `$_SERVER['HTTPS']` may be unset and you will rebuild an `http://` URL that never matches. / Limits ## What does Twilio **not** protect you from? The expensive things. 1. **Sending to an unregistered US number.** Error `30034 — US A2P 10DLC: Message from an Unregistered Number` blocks the message outright. Correct code, valid credentials, real phone number, and nothing arrives, because Brand and Campaign registration is a business process, not a code path. It also fires if the number is simply missing from the Sender Pool of the registered Messaging Service. 2. **Consent.** Twilio will happily deliver a marketing message to someone who only ever bought a product. Transactional order updates and marketing are different legal categories, and the checkbox that separates them is yours to build and store. 3. **Spend.** There is no per-order or per-customer cap by default. A retry loop that re-fires a status transition sends — and bills — every time. The order ID plus target status makes a natural idempotency key; use it. 4. **Your credentials at rest.** The auth token signs the callbacks as well as authenticating the sends, so it is both a spending key and a forgery key. It belongs in `wp-config.php` or the environment, never in the options table and never in a file under the webroot. **An empty phone is not an error worth escalating.** With the phone field optional, a store will always have orders with nothing to send to. Log it and move on — throwing from a status-transition callback risks disrupting the order flow itself, which is a far more expensive failure than a missed text message. /Footnotes ¹ Hook ordering and the empty-`from` branch verified in [class-wc-order.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/class-wc-order.php) against WooCommerce 10.8.1. ² Validator body and checkout field definition from `includes/class-wc-validation.php` and `includes/class-wc-countries.php` in the same release. ³ Endpoint, parameters, segment limits, `ValidityPeriod` range and the status value list from the [Twilio Message resource](https://www.twilio.com/docs/messaging/api/message-resource) reference. ⁴ Signature algorithm and URL-encoding rules from [Twilio webhook security](https://www.twilio.com/docs/usage/webhooks/webhooks-security); error codes [21211](https://www.twilio.com/docs/api/errors/21211) and [30034](https://www.twilio.com/docs/api/errors/30034). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WooCommerce SMS Order Notification: Build It on Twilio","description":"Build WooCommerce SMS order notifications on Twilio: the right status hook, E.164 phone normalisation, per-segment cost maths, and signed delivery callbacks.","datePublished":"2026-08-07","dateModified":"2026-08-07","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/woocommerce-sms-order-notification-twilio/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/woocommerce-sms-order-notification-twilio/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration titled \"Sending WooCommerce Order SMS Through Twilio Programmable Messaging\": a neon server rack badged with the Woo logo and \"ORD-7359 (status: processing)\" streams a packet trail into a \"Twilio — Programmable Messaging\" panel showing API, gear and SMS nodes, which forwards to a phone displaying an SMS bubble."},"keywords":["woocommerce sms notification","woocommerce order notification sms","twilio woocommerce","woocommerce sms order status","twilio programmable messaging wordpress","woocommerce order status changed hook"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WooCommerce SMS Order Notification: Build It on Twilio","item":"https://wpwebhooks.org/blog/woocommerce-sms-order-notification-twilio/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Which WooCommerce hook should trigger an order SMS?","acceptedAnswer":{"@type":"Answer","text":"The per-status hook, woocommerce_order_status_processing or woocommerce_order_status_completed. The generic woocommerce_order_status_changed only fires when a previous status existed, so an order created directly into processing by an instant-capture gateway never reaches it."}},{"@type":"Question","name":"Why does Twilio reject the WooCommerce billing phone number?","acceptedAnswer":{"@type":"Answer","text":"Because WC_Validation::is_phone() only checks that the string contains allowed characters — digits, spaces and some punctuation. It never validates a country code or normalises to E.164. Values like (555) 123-4567 pass checkout and are rejected by Twilio with error 21211, Invalid To Phone Number."}},{"@type":"Question","name":"How much does a WooCommerce SMS notification cost?","acceptedAnswer":{"@type":"Answer","text":"You are billed per segment, not per message. A segment is 160 GSM-7 characters, or 70 UCS-2 characters once any character falls outside GSM-7. Adding a single emoji to a 150-character message turns 1 segment into 3, tripling the cost of every notification you send."}},{"@type":"Question","name":"Does a 201 response from Twilio mean the SMS was delivered?","acceptedAnswer":{"@type":"Answer","text":"No. The status in that response is queued. Only delivered, undelivered, failed and canceled are terminal states, and they arrive later on the StatusCallback URL. Verify those callbacks with the X-Twilio-Signature header, an HMAC-SHA1 of the full URL plus alphabetically sorted parameters, keyed with your auth token."}},{"@type":"Question","name":"Why are my WooCommerce SMS messages blocked in the United States?","acceptedAnswer":{"@type":"Answer","text":"Most likely Twilio error 30034, US A2P 10DLC message from an unregistered number. Application-to-person traffic to US numbers requires an approved Brand and Campaign registration, and the sending number must be in the Sender Pool of the linked Messaging Service. No code change fixes it."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/woocommerce-sms-twilio.png","caption":"FIG 01 — From order transition to a billed SMS segment","description":"A WooCommerce order status transition fires two different hooks. The per-status hook fires on every transition including the first one, while woocommerce_order_status_changed only fires when there is a previous status, so a brand new order never reaches it. The chosen handler reads the billing phone, which WooCommerce validates only as a loose set of allowed characters and never normalises to E.164, so it must be converted before use. The send is queued rather than run inline, then posted to the Twilio Messages endpoint. Twilio answers with a queued status, and the real outcome arrives later on a signed status callback, where delivered, undelivered and failed are the terminal states.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WPForms Webhooks Addon: Setup and a Free Alternative" description: "WPForms webhook setup: the Elite-only Webhooks addon, and the free wpforms_process_complete hook for when you need your own payload and headers." url: "https://wpwebhooks.org/blog/wpforms-webhook/" date: "2026-08-06" --- # WPForms Webhooks Addon: Setup and a Free Alternative **TL;DR:** WPForms can send an entry to a webhook two ways, and they sit on opposite sides of a paywall. - **No code:** the _Webhooks_ addon adds a request panel to the form builder — but it needs an **Elite** licence. - **With code:** hook `wpforms_process_complete`, which fires at the very end of a successful submission and works on any tier, including Lite. - The hook hands you `$fields`, `$entry`, `$form_data` and `$entry_id` — everything needed to build your own payload. - `$entry_id` is `0` on Lite or with entry storage off, so never use it as your idempotency key. - Either route sends the request inline unless you queue it, so a slow endpoint becomes slow form submissions. / Overview ## Does WPForms have a **webhook**? Yes, and there are two entirely separate routes to one. The first is the Webhooks addon, which puts a request builder in the form editor and needs no PHP. The second is `wpforms_process_complete`, an action WPForms fires at the end of every successful submission, which you handle in a snippet. Which one you can use is mostly a licensing question. WPForms documents the Webhooks addon as requiring an [Elite licence level](https://wpforms.com/docs/how-to-install-and-use-the-webhooks-addon-with-wpforms/), the top tier — Basic, Plus and Pro do not include it. The action hook is part of the plugin's own processing code, so it is available on every tier including the free WPForms Lite. That makes the decision unusually clear compared with other form plugins. If you are already on Elite and your endpoint is happy with the payload WPForms produces, use the addon. In every other case the hook is the route, and it is not much work. FIG 01 — Two routes from a WPForms entry to your endpoint / Built-in addon ## What does the **Webhooks addon** actually send? Whatever you tell it to, within a fixed set of options. The addon exposes five request methods — GET, POST, PUT, PATCH and DELETE — and two body formats: JSON, sent as `application/json`, or form-encoded, sent as `application/x-www-form-urlencoded`. You map form fields to request keys in the builder rather than accepting a fixed payload shape, which is the main thing it does better than the built-in webhook actions in most competing form plugins. Two options matter for talking to a real API. Custom request headers let you pass an API key, which is the difference between a webhook you can point at a service and one you can only point at a catch-all URL. A Secret field generates a per-request hash sent as a header, so the receiver can confirm the request came from your site rather than anyone who learned the URL. The limits are the shape of the payload and the shape of the failure. You get a flat mapping of fields to keys, so an endpoint expecting nested objects or a computed value needs a translation layer somewhere. And a request that fails is a request that failed — the addon is a sender, not a delivery system with its own retry schedule. / The hook ## What is **wpforms\_process\_complete**? It is the action that runs at the very end of successful entry processing. WPForms documents the [signature](https://wpforms.com/developers/wpforms_process_complete/) as four parameters: | Parameter | Type | What it holds | | --- | --- | --- | | `$fields` | `array` | Sanitised entry field values and properties, keyed by field ID. | | `$entry` | `array` | The original $\_POST payload for the submission. | | `$form_data` | `array` | The processed form settings — including the form ID and title. | | `$entry_id` | `int` | The saved entry ID, or 0 when entry storage is off or the site runs Lite. | The timing is the important part. It fires only when the submission passed validation, and only after the entry has been written and notification emails have gone out. So by the time your callback runs, the entry is real and the user is already being redirected or shown the confirmation. Nothing you do here can reject the submission — for that you want the earlier [wpforms\_process](https://wpforms.com/developers/wpforms_process/) action, which is where validation errors belong. > wpforms\_process\_complete runs at the very end of successful form entry processing — after the entry is saved and the notifications are sent. — WPForms developer documentation / Implementation ## How do you post a WPForms entry to your own **API**? Bind a callback, guard it to one form, build the body you want, and send it with [wp\_remote\_post](https://developer.wordpress.org/reference/functions/wp_remote_post/). The form guard is not optional — without it every form on the site posts to your endpoint, including the contact form you forgot about. PHP — send a WPForms entry to an endpoint ``` add_action( 'wpforms_process_complete', function( $fields, $entry, $form_data, $entry_id ) { // Only the form we care about. if ( 42 !== (int) $form_data['id'] ) { return; } // $fields is keyed by field ID; 'name' is the label, 'value' the answer. $payload = [ 'form' => $form_data['settings']['form_title'], 'entry_id' => $entry_id ?: null, 'answers' => wp_list_pluck( $fields, 'value', 'name' ), ]; wp_remote_post( 'https://example.com/hooks/wpforms', [ 'timeout' => 5, 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . getenv( 'HOOK_TOKEN' ), ], 'body' => wp_json_encode( $payload ), 'blocking' => true, ] ); }, 10, 4 ); ``` Two details in there are easy to get wrong. The `4` at the end is the argument count — omit it and PHP hands your callback one parameter, so `$form_data` is undefined and the guard fails. And `$fields` is keyed by numeric field ID, not by label, which is why the example flattens it with `wp_list_pluck` before sending. Field IDs are stable across label edits; that is a feature, not an inconvenience. / Entry IDs ## Why does **$entry\_id** come back as 0? Because nothing was saved. WPForms returns `0` when entry storage is disabled for that form or when the site is running WPForms Lite, which does not store entries at all. The submission still processed and your callback still ran — there is simply no row to point at. This matters more than it looks. A receiving endpoint usually wants an idempotency key so a replayed request does not create a duplicate record downstream, and `$entry_id` is the obvious candidate. On a Lite site every request would arrive with the same key of `0`, and a well-behaved consumer would discard all but the first. Generate your own identifier instead — a UUID minted in the callback, or a hash of the form ID and the submission timestamp — and treat `$entry_id` as a convenience link back to wp-admin when it happens to be non-zero. / Delivery ## What happens when the endpoint is **slow or down**? The visitor waits, and then the data is gone. A blocking `wp_remote_post` inside the hook adds the endpoint's full response time to the submission, and the default WordPress HTTP timeout is 5 seconds. A receiver having a bad afternoon at 4 seconds per request means every person filling in your form waits an extra 4 seconds after pressing submit for no visible reason. The failure mode is worse than the latency. If the request times out or returns a 500, the visitor still sees the success message — the entry saved fine, only the delivery failed — and nothing anywhere records that the lead never reached your CRM. This is the same trap that catches [Elementor form deliveries](https://wpwebhooks.org/blog/elementor-forms-webhook/) and [Contact Form 7 submissions](https://wpwebhooks.org/blog/wordpress-wpcf7-mail-sent-hook/), and the fix is the same in all three: do not send in the request, enqueue. | Behaviour | wp\_remote\_post in the hook | Queued delivery | | --- | --- | --- | | Submission time | Visitor waits for the endpoint | Returns immediately | | Endpoint down | Request lost, no record | Stays queued, retried later | | Retries | None | Exponential backoff until a cap | | Visibility | Nothing to inspect | Per-attempt log with request and response | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Debugging ## How do you debug a webhook that **never arrives**? Work outwards from the hook. Most of these turn out to be the guard or the argument count rather than anything to do with HTTP. 1. **Confirm the hook runs at all.** Drop an `error_log( 'process_complete fired' )` as the first line of the callback and submit the form. No line in the log means the submission failed validation, or the snippet is not loaded. 2. **Confirm the form guard matches.** Log `$form_data['id']` and compare it with the ID in the form's edit URL. Comparing a string to an integer without a cast is the classic version of this bug. 3. **Check the argument count.** If `$form_data` is `null`, the fourth argument to `add_action` is missing or wrong. 4. **Log the response, not just the request.** `wp_remote_post` returns either a `WP_Error` or an array — log `wp_remote_retrieve_response_code()` so a 401 is not mistaken for a request that never left. 5. **Rule out the outbound connection.** A host that blocks external HTTP, or a firewall that drops the destination, produces a `WP_Error` with a connection message and no server-side trace at the receiver. If step 5 is where it fails, that is an infrastructure problem, not a code one, and no amount of rewriting the callback will fix it. The same `wpforms_process_complete` callback is the starting point for any destination, not just your own API — [posting entries into a Slack channel](https://wpwebhooks.org/blog/wordpress-form-to-slack/) uses it alongside the Contact Form 7 and Gravity Forms equivalents, feeding one shared payload builder. /Footnotes ¹ Signature and parameter descriptions from the WPForms developer reference for [wpforms\_process\_complete](https://wpforms.com/developers/wpforms_process_complete/). ² Licence requirement, request methods, body formats, custom headers and the Secret option from the [WPForms Webhooks addon documentation](https://wpforms.com/docs/how-to-install-and-use-the-webhooks-addon-with-wpforms/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WPForms Webhooks Addon: Setup and a Free Alternative","description":"WPForms webhook setup: the Elite-only Webhooks addon, and the free wpforms_process_complete hook for when you need your own payload and headers.","datePublished":"2026-08-06","dateModified":"2026-08-06","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wpforms-webhook/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/og_image.jpg","width":1200,"height":630,"caption":"WPForms Webhooks Addon: Setup and a Free Alternative"},"keywords":["wpforms webhooks","wpforms webhook addon","wpforms process complete","wpforms form submission hook","wordpress form webhook","wpforms send data to api","wpforms elite addon","wpforms entry hook"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WPForms Webhooks Addon: Setup and a Free Alternative","item":"https://wpwebhooks.org/blog/wpforms-webhook/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does WPForms have a webhook?","acceptedAnswer":{"@type":"Answer","text":"Yes, two ways. The Webhooks addon adds a no-code webhook panel to the form builder, but it requires an Elite licence. On any tier you can hook wpforms_process_complete in PHP and send the request yourself, which is also the only route that lets you shape the payload exactly."}},{"@type":"Question","name":"Which WPForms licence do I need for webhooks?","acceptedAnswer":{"@type":"Answer","text":"Elite. WPForms documents the Webhooks addon as an Elite-level addon, so Lite, Basic, Plus and Pro licences do not have it. The wpforms_process_complete action is part of the plugin itself rather than an addon, so a code-based webhook works on any tier including WPForms Lite."}},{"@type":"Question","name":"What is wpforms_process_complete?","acceptedAnswer":{"@type":"Answer","text":"It is the action WPForms fires at the very end of a successful entry submission, after the entry has been saved and notification emails have been sent. Its signature is do_action('wpforms_process_complete', $fields, $entry, $form_data, $entry_id), and it does not fire when validation fails."}},{"@type":"Question","name":"Why is $entry_id always 0 in wpforms_process_complete?","acceptedAnswer":{"@type":"Answer","text":"Because entry storage is off. WPForms documents $entry_id as returning 0 when entry storage is disabled or the site is running WPForms Lite. Use it for a link back to the entry when it is non-zero, but never make it the idempotency key your endpoint deduplicates on."}},{"@type":"Question","name":"What request methods and formats does the WPForms Webhooks addon support?","acceptedAnswer":{"@type":"Answer","text":"GET, POST, PUT, PATCH and DELETE, with the body sent either as JSON (application/json) or as form-encoded data (application/x-www-form-urlencoded). You can add custom request headers for an API key, and a Secret option adds a per-request hash header the receiver can check."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wpforms-webhook.png","caption":"FIG 01 — Two routes from a WPForms entry to your endpoint","description":"A visitor submits a WPForms form. WPForms validates the fields, saves the entry and sends its email notifications. On an Elite licence the Webhooks addon then sends the request configured in the form builder, using the method, format and headers set there. Independently of licence tier, WPForms fires the wpforms_process_complete action at the very end of successful processing, where a PHP callback can build its own payload and send or enqueue it. Both routes reach an external endpoint, but only the code route lets you reshape the body or hand the delivery to a background queue.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Twilio Verify WordPress: Build an SMS & Email 2FA Flow" description: "Add Twilio Verify 2FA to WordPress: the two API calls, the REST routes that wrap them, and the abuse controls Twilio will not handle for you." url: "https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/" date: "2026-08-05" --- # Twilio Verify WordPress: Build an SMS & Email 2FA Flow **TL;DR:** Twilio Verify turns two-factor auth into two API calls, and moves the hard parts of code handling off your server. - **Send:** `POST /v2/Services/{ServiceSid}/Verifications` with `To` and `Channel`. - **Check:** `POST /v2/Services/{ServiceSid}/VerificationCheck` with `To` and `Code`. - Twilio generates, delivers, expires and rate-limits the code. **You never store a passcode.** - Codes last **10 minutes**, allow **5 check attempts**, then the verification is deleted. - The route that triggers a send has to be public — so rate limiting, enumeration defence and abuse control are entirely yours. / Overview ## What does **Twilio Verify** actually give you? It gives you the part of two-factor authentication that is boring to build and unpleasant to get wrong: generating a random code, delivering it over a channel the user chose, remembering it for exactly long enough, and refusing it after too many guesses. Your application sends a request saying "verify this address" and later asks "is this code correct". The thing worth noticing is what leaves your database. A hand-rolled OTP implementation stores a code — usually hashed, often not — against a user, with an expiry column and an attempt counter, and every one of those is a place to introduce a bug. With [Verify](https://www.twilio.com/docs/verify/api/verification), the code lives on Twilio's side for its whole lifetime. There is no passcode column in your schema, so there is no passcode to leak, no expiry check to forget, and no attempt counter to increment in a race. What you build instead is the glue: routes the browser can call, a mapping from a user to the destination you will actually send to, and the decision about what a successful check entitles someone to do. FIG 01 — The two Verify calls, and where your code sits either side / Setup ## What do you need before writing any **PHP**? A Verify Service, and three values. The Service is a container in the Twilio console that holds the settings shared by every verification you send — the friendly name shown in the message, code length, which channels are enabled — and it has its own SID beginning `VA`. That is separate from your Account SID. | Value | Looks like | Where it comes from | | --- | --- | --- | | Account SID | `AC…` | Twilio console dashboard | | Auth Token | a secret string | Twilio console dashboard — treat as a password | | Service SID | `VA…` | Verify → Services, one per application | Install the official SDK with `composer require twilio/sdk`. It is a thin wrapper over the REST API, and the [twilio-php](https://github.com/twilio/twilio-php) client is the only dependency the integration needs. **Every PHP example below uses that official `twilio/sdk` package** — the `$client`, the `->verify->v2` chain and the `\Twilio\Exceptions\RestException` class all come from it. Nothing here calls the REST API over raw `wp_remote_post`. You can talk to Verify with plain HTTP if you would rather not add the dependency, but then the exception types and the fluent chain in these snippets do not apply. **Do not put the credentials in a `.env` inside the plugin directory.** Anything under the webroot is one server misconfiguration away from being downloadable, and a leaked Auth Token is an account someone else can spend money from. Put them in `wp-config.php` above the webroot, or in real environment variables set by the server. / Sending ## How do you **send** a verification code? One call, two required parameters. `To` is a phone number in E.164 format (`+441234567890` — the leading plus is not optional) or an email address, and `Channel` is how it should arrive. PHP (twilio/sdk) — send a code ``` // Requires the official Twilio PHP SDK: composer require twilio/sdk use TwilioRestClient; $client = new Client( TWILIO_SID, TWILIO_TOKEN ); try { $verification = $client->verify->v2 ->services( TWILIO_SERVICE_ID ) ->verifications ->create( $to, $channel ); // 'sms' | 'call' | 'email' | 'whatsapp' // $verification->status === 'pending' } catch ( TwilioExceptionsRestException $e ) { // 60200 invalid parameter, 60203 max send attempts, 60410 blocked by Fraud Guard error_log( 'verify send failed: ' . $e->getStatusCode() . ' ' . $e->getMessage() ); } ``` Catching `RestException` specifically rather than `\Exception` matters, because it is the only one that carries `getStatusCode()` and `getMoreInfo()`. A generic catch that returns `$e->getCode()` gives you Twilio's application error number in some paths and a PHP error code in others, and the caller cannot tell which it got. A successful send returns status `pending`. That means delivery was accepted, not that anyone received anything — a disconnected number or a spam-filtered inbox still returns `pending`. / Checking ## How do you **check** the code the user typed? The mirror call. Pass the same `To` and the code, and read one field on the response. PHP (twilio/sdk) — check a code ``` // $client is the same TwilioRestClient from the SDK, constructed above. $check = $client->verify->v2 ->services( TWILIO_SERVICE_ID ) ->verificationChecks ->create( [ 'to' => $to, 'code' => $code ] ); if ( 'approved' === $check->status ) { // and ONLY here } ``` Compare against `approved` and nothing else. The status field has seven possible values, and treating "not failed" as success is how people accidentally let `pending` through: | Status | What it means | Let the user through? | | --- | --- | --- | | `approved` | The code matched | Yes — this value only | | `pending` | Sent, not yet correctly answered | No | | `expired` | Past the validity window | No | | `max_attempts_reached` | Too many wrong guesses | No | | `canceled` / `failed` / `deleted` | Terminated or unsuccessful | No | There is a sharp edge in the failure path. Twilio deletes the verification once it is approved, expires, or hits the attempt limit — so a check against a verification that no longer exists returns a **404**, not a status. Code that only inspects `$check->status` and lets exceptions bubble will crash on the sixth wrong guess rather than telling the user they have run out of attempts. / Limits ## What are the built-in **limits**? Three numbers do most of the work, and they are the reason you do not need your own expiry and attempt-counter logic. | Limit | Default | What happens at the boundary | | --- | --- | --- | | Code lifetime | 10 minutes | Status becomes `expired`; configurable 2 min – 24 h via support | | Check attempts | 5 per verification | [Error 60202](https://www.twilio.com/docs/api/errors/60202), status `max_attempts_reached` | | Send attempts | per service rate limits | [Error 60203](https://www.twilio.com/docs/api/errors/60203), max send attempts reached | Once a verification hits the attempt limit, a new one cannot be created for that destination until the existing one expires. So the worst case a user can talk themselves into is a ten-minute wait — which is a sensible product behaviour you get without writing a lockout table, but it is also a support call you should write copy for. > Twilio deletes the verification once it is approved, expired, or out of attempts. There is nothing on your side to clean up — and nothing to check against either. — the rule that saves you a lockout table / REST routes ## How should the **WordPress routes** be shaped? Two `POST` routes registered on `rest_api_init`, one per Verify call, with a JSON schema on each so malformed input is rejected before it reaches Twilio. WordPress will do the validation for you if you hand it a schema through [register\_rest\_route](https://developer.wordpress.org/reference/functions/register_rest_route/). PHP — route registration with a validated schema ``` add_action( 'rest_api_init', function () { register_rest_route( 'your-plugin/v1', '/verificationCheck', [ 'methods' => 'POST', 'callback' => 'your_plugin_check', 'permission_callback' => 'your_plugin_gate', // never __return_true 'args' => [ 'email' => [ 'required' => true, 'type' => 'string', 'format' => 'email', 'sanitize_callback' => 'sanitize_email', ], 'code' => [ 'required' => true, 'type' => 'string', 'pattern' => '^d{6}$', ], ], ] ); } ); ``` Declaring the arguments in `args` rather than parsing `$request->get_body()` by hand is the difference between validation you maintain and validation the platform maintains. WordPress runs the schema, returns a properly formed `rest_invalid_param` error, and your callback only ever runs on input that already matched. / The handoff ## What happens after a code is **approved**? For a password-reset flow, the approved check is the point where you mint a reset token — and you should use WordPress's own, not invent one. [get\_password\_reset\_key](https://developer.wordpress.org/reference/functions/get_password_reset_key/) generates a key, stores its hash against the user, and stamps it with a time. The matching [check\_password\_reset\_key](https://developer.wordpress.org/reference/functions/check_password_reset_key/) validates it on the way back, honouring the standard expiry. PHP — approved check to reset key ``` if ( 'approved' !== $check->status ) { return new WP_REST_Response( [ 'ok' => false ], 401 ); } $key = get_password_reset_key( $user ); if ( is_wp_error( $key ) ) { return new WP_REST_Response( [ 'ok' => false ], 500 ); } // Hand back a short-lived key the next request must present. return new WP_REST_Response( [ 'ok' => true, 'token' => $key ], 200 ); ``` Two things are easy to get wrong here. The first is returning HTTP `200` with a failure encoded in the body — a client that checks the transport status sees success, and every logging and monitoring layer between you and the user agrees with it. Let the HTTP status carry the outcome. The second is reaching for `wp_set_auth_cookie()` instead of a reset key: passing a code proves control of a phone or mailbox, which is exactly the right basis for resetting a password and a much weaker basis for handing out a logged-in session. ![Cyberpunk illustration of a developer at a wall of neon terminals, facing a green holographic panel that reads "Adding Twilio Verify Two-Factor Auth to WordPress", with a "SECURE YOUR SITE" padlock card and a phone projecting a fingerprint above a "VERIFY — Text message" notification carrying the Twilio logo.](https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/og_image.jpg) / Abuse ## What does Twilio **not** protect you from? Your own endpoint. This is the part that gets skipped, because everything above works perfectly in testing and the gap only shows up on a bill. Note that this list is specific to Verify. Twilio's Programmable Messaging API — the one behind [WooCommerce order SMS](https://wpwebhooks.org/blog/woocommerce-sms-order-notification-twilio/) — has a different set of traps, starting with A2P 10DLC registration and per-segment billing. A lost-password flow is used by people who cannot log in, so the send route must be callable by anonymous visitors. That means a permission callback that returns `true` unconditionally, which is the natural thing to write, hands anyone on the internet a button that makes your site pay to send a message. That is [SMS pumping](https://www.twilio.com/docs/verify/preventing-toll-fraud): an attacker drives volume to numbers they profit from, and you are the one being invoiced. Twilio's Fraud Guard, geographic permissions and service rate limits help, and Twilio is explicit that no provider-side control is complete on its own. | Concern | Public route, no gate | What to do instead | | --- | --- | --- | | Send abuse | Anyone can trigger unlimited sends | Rate limit per IP and per account before calling Twilio | | Account discovery | 404 "user not found" vs 200 reveals who is registered | Identical response and timing either way | | CSRF token | An endpoint that hands out a fresh nonce to any caller | Nonce tied to a session, or drop the pretence and rate limit properly | | Destination | Number taken from the request body | Number read from stored user meta, never from the caller | The last row is the one that turns a costly problem into a contained one. If the destination comes from the request, the endpoint will send to any number in the world on request. If it is looked up from the account's stored contact details, an attacker can at worst spam a real user's real phone — still worth rate limiting, but no longer a payout mechanism. The enumeration point is subtler and just as real. Returning a distinct `404` for an unknown address turns the endpoint into an oracle for which email addresses have accounts, which is a useful shopping list for credential stuffing. Answer the same way in both cases, and do the work of looking busy for the same length of time. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Delivery ## Should the API call happen **in the request**? Here, yes — and it is worth being clear about why, because it is the opposite of the advice for most outbound calls. The user is standing at a form waiting to be told whether a code was sent, so the send is genuinely synchronous work; deferring it to a queue would mean answering "we will send something shortly", which is not a login experience anyone wants. What you should do is bound it. Set an explicit timeout on the HTTP client rather than accepting the default, decide what the form says when Twilio is slow, and make sure a Twilio outage produces a clear error instead of a hung request. The asynchronous pattern that suits [outbound notifications](https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/) is the wrong shape for an interactive step in an authentication flow — the same reasoning, applied to a case where the answer comes out differently. /Footnotes ¹ Endpoints, channels and status values from the Twilio [Verification](https://www.twilio.com/docs/verify/api/verification) and [VerificationCheck](https://www.twilio.com/docs/verify/api/verification-check) API references. ² The 10-minute default lifetime and its 2 minute – 24 hour configurable range are documented under [Rate Limits and Timeouts](https://www.twilio.com/docs/verify/api/rate-limits-and-timeouts); the 5-attempt check limit is error [60202](https://www.twilio.com/docs/api/errors/60202). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Twilio Verify WordPress: Build an SMS & Email 2FA Flow","description":"Add Twilio Verify 2FA to WordPress: the two API calls, the REST routes that wrap them, and the abuse controls Twilio will not handle for you.","datePublished":"2026-08-05","dateModified":"2026-08-05","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of a developer at a wall of neon terminals, facing a green holographic panel that reads \"Adding Twilio Verify Two-Factor Auth to WordPress\", with a \"SECURE YOUR SITE\" padlock card and a phone projecting a fingerprint above a \"VERIFY — Text message\" notification carrying the Twilio logo."},"keywords":["twilio verify wordpress","wordpress 2fa api","twilio 2fa wordpress","wordpress sms verification","twilio verify php","wordpress two factor rest api","twilio verificationcheck","wordpress otp implementation"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Twilio Verify WordPress: Build an SMS & Email 2FA Flow","item":"https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do I add Twilio Verify 2FA to WordPress?","acceptedAnswer":{"@type":"Answer","text":"Create a Verify Service in the Twilio console, install the twilio/sdk package, and wrap two API calls in your own REST routes: Verifications to send a code and VerificationCheck to validate it. Twilio generates, delivers, stores and expires the code, so your plugin never stores a one-time passcode itself."}},{"@type":"Question","name":"What are the two Twilio Verify API calls?","acceptedAnswer":{"@type":"Answer","text":"POST /v2/Services/{ServiceSid}/Verifications with To and Channel sends a code, and POST /v2/Services/{ServiceSid}/VerificationCheck with To and Code validates it. The check returns a status of approved when the code matches; anything else — pending, expired, max_attempts_reached — means do not let the user through."}},{"@type":"Question","name":"How long is a Twilio Verify code valid?","acceptedAnswer":{"@type":"Answer","text":"Ten minutes by default, and the window is configurable between 2 minutes and 24 hours through Twilio support. Twilio deletes the verification once it is approved, expires, or reaches the maximum number of check attempts, which is 5 by default."}},{"@type":"Question","name":"Does Twilio Verify protect my WordPress site from SMS pumping?","acceptedAnswer":{"@type":"Answer","text":"Only partly. Twilio offers SMS Fraud Guard, geographic permissions and service-level rate limits, but the endpoint that triggers a send is your own public REST route. If a logged-out visitor can POST an arbitrary phone number to it without a rate limit, they can make your site pay for messages. That control is yours to build."}},{"@type":"Question","name":"Should the 2FA REST route use a permission callback?","acceptedAnswer":{"@type":"Answer","text":"It still needs one, but it cannot require a logged-in user — a lost-password flow is used by people who cannot log in. Return true only after your own checks: a per-IP and per-account rate limit, a generic response whether or not the account exists, and a nonce that is genuinely bound to a session rather than one any caller can request."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/twilio-verify-wordpress.png","caption":"FIG 01 — The two Verify calls, and where your code sits either side","description":"The browser posts an identifier to a public WordPress REST route. That route resolves the user, looks up the destination it will actually send to, and calls the Twilio Verify Verifications endpoint, which generates and delivers the code. Twilio stores the code, not your site. The user then submits the code to a second REST route, which calls VerificationCheck. Twilio answers approved or pending, and only on approved does the WordPress side mint a password reset key and hand it back. Rate limiting, user enumeration and abuse control sit on your routes, because both routes must be reachable by logged-out visitors.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "DISABLE_WP_CRON Is Set to True: WP-Cron Spawning Fix" description: "The DISABLE_WP_CRON constant is set to true, WP-Cron spawning is disabled, and Maximum simultaneous queues already in progress — what each warning means." url: "https://wpwebhooks.org/blog/disable-wp-cron-warnings/" date: "2026-08-03" --- # DISABLE_WP_CRON Is Set to True: WP-Cron Spawning Fix **TL;DR:** Three warnings that look alike come from three different layers, and only one of them is usually a real fault. - **"The DISABLE\_WP\_CRON constant is set to true. WP-Cron spawning is disabled."** — a WP-CLI message describing your configuration. Fine if a real cron job replaces it, fatal if nothing does. - **"The ALTERNATE\_WP\_CRON constant is set to true. WP-Cron spawning is not asynchronous."** — cron runs on a redirect instead of a background request. - **"Maximum simultaneous queues already in progress (1 queue)."** — Action Scheduler, not WP-Cron. The concurrency limit is 1 by default. - A missing WooCommerce daily event is a _registration_ problem, not a running problem. - The only test that matters: did a due event actually run in the last few minutes? / The message ## What does **"The DISABLE\_WP\_CRON constant is set to true"** mean? It means exactly what it says, and it is a description of your configuration rather than a diagnosis of a problem. The wording comes from WP-CLI: the [wp cron test](https://developer.wordpress.org/cli/commands/cron/test/) command tries to spawn WP-Cron over HTTP, and the first thing it does is refuse to try when the constant is set. wp-cli/cron-command — Cron\_Command::test() ``` if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) { WP_CLI::error( 'The DISABLE_WP_CRON constant is set to true. WP-Cron spawning is disabled.' ); } if ( defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON ) { WP_CLI::warning( 'The ALTERNATE_WP_CRON constant is set to true. WP-Cron spawning is not asynchronous.' ); } ``` "Spawning" is the specific thing being reported. WordPress normally checks for due events on page loads and, when it finds one, fires a non-blocking request to `wp-cron.php` to run it. That self-triggering is what the constant disables. Nothing about scheduling changes: events are still registered, still stored, still due. They just have nobody to run them. FIG 01 — Which cron warning you are looking at, and what causes it / Is it a fault? ## Is **DISABLE\_WP\_CRON** on its own a problem? No — on a busy site it is the recommended setup. Leaving the default in place means every page load carries a scheduling check, and on a site with real traffic that is thousands of pointless checks an hour to run a job that needs to fire once. Setting the constant and handing the schedule to the operating system is the standard fix, and it makes cron timing predictable instead of traffic-dependent. It becomes a fault the moment nothing replaces it. The constant is one half of a two-part change, and the second half lives outside WordPress: crontab — the half that has to exist ``` # wp-config.php # define( 'DISABLE_WP_CRON', true ); # crontab -e — hit wp-cron.php once a minute * * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1 # or, better, skip HTTP entirely * * * * * cd /var/www/example && wp cron event run --due-now --quiet ``` So the question to answer is never "is the constant set" — it is "is anything hitting the scheduler". Our [WP-Cron developer reference](https://wpwebhooks.org/blog/wp-cron-developer-reference/) covers the scheduling API itself; what follows here is about reading the warnings. / Alternate cron ## What does the **ALTERNATE\_WP\_CRON** warning mean? That cron is running, but through a redirect rather than a background request. With `ALTERNATE_WP_CRON` enabled, WordPress appends a query string to a visitor's page load and redirects them, running the due events during that redirected request. It exists for hosts where the site cannot make loopback requests to itself — the mechanism WP-Cron normally relies on. WP-CLI reports it as a warning rather than an error because it works, with caveats. Cron work now happens inside a real visitor's request, so a slow job is a slow page for whoever was unlucky enough to trigger it, and the redirect can interact badly with page caching. It is also still traffic-dependent: no visitors, no cron. Managed hosts sometimes turn this on as a platform feature, which is why it shows up on sites nobody deliberately configured — the [WP Engine cron setup](https://wpwebhooks.org/blog/wp-engine-cron-jobs/) is the best-known example. / Action Scheduler ## What does **"Maximum simultaneous queues already in progress"** mean? This one is not WP-Cron at all. It is an Action Scheduler admin notice, and it appears on the Scheduled Actions screen when the number of open claims has reached the allowed concurrency. The full string is pluralised on the claim count, which is why people see it as "(1 queue)": ActionScheduler\_ListTable::display\_admin\_notices() ``` if ( $this->runner->has_maximum_concurrent_batches() ) { $claim_count = $this->store->get_claim_count(); // 'Maximum simultaneous queues already in progress (%s queue). // No additional queues will begin processing until the current queues are complete.' } // ActionScheduler_Abstract_QueueRunner public function has_maximum_concurrent_batches() { return $this->store->get_claim_count() >= $this->get_allowed_concurrent_batches(); } public function get_allowed_concurrent_batches() { return apply_filters( 'action_scheduler_queue_runner_concurrent_batches', 1 ); } ``` The default limit is **1**. One batch running means the claim count is 1, which is already `>= 1`, which shows the notice. On a healthy site that is a normal thing to catch mid-pass and it clears by itself. It only signals trouble when the queue stops draining while the notice stays up, which means a claim is held by something that is never going to finish. | Symptom | Likely cause | Where to look | | --- | --- | --- | | Notice clears within a minute | A batch was simply running | Nothing to do | | Notice permanent, pending count falling | Throughput limit, not a stall | `action_scheduler_queue_runner_concurrent_batches` | | Notice permanent, pending count rising | Stale claim holding the only slot | In-progress actions older than the batch time limit | | No actions ever move to complete | Nothing is triggering the runner | Whether cron runs at all | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Stuck claims ## How do you clear a **stuck queue**? Find the claim holder before changing any limits. A batch has a time limit — 30 seconds by default — so an action sitting in-progress for an hour is not slow, it is abandoned, usually because the PHP process running it was killed mid-execution and never got to release the claim. 1. **Sort by status.** On _Tools → Scheduled Actions_, filter to _In-progress_ and check the started time. Anything far past the batch time limit is a candidate. 2. **Deal with the action, not the queue.** Cancel it, or re-run it once you know why it hung. A single action that consistently exceeds the time limit will keep recreating this state. 3. **Drain the backlog explicitly.** `wp action-scheduler run --batches=0` keeps processing until the queue is empty, which is far faster than waiting for minute-by-minute cron ticks. 4. **Only then consider concurrency.** Raising `action_scheduler_queue_runner_concurrent_batches` adds throughput on a site that is genuinely saturated — and multiplies the deadlock risk on the claims table if you push it hard. That last point is a real trade-off, not a caution: concurrent runners compete for the same rows when claiming, which is the mechanism behind [Action Scheduler deadlocks under load](https://wpwebhooks.org/blog/woocommerce-action-scheduler-mysql-deadlocks/). Action Scheduler's own [performance documentation](https://actionscheduler.org/perf/) treats the default of one batch as a deliberately conservative starting point. / Missing events ## Why is a **WooCommerce daily event** missing from the schedule? Because it was never re-registered. WooCommerce schedules a fixed set of recurring hooks on activation — `woocommerce_scheduled_sales`, `woocommerce_cleanup_sessions`, `woocommerce_cleanup_personal_data`, `woocommerce_cleanup_logs`, `woocommerce_cancel_unpaid_orders`, `woocommerce_geoip_updater`, `woocommerce_tracker_send_event` and `woocommerce_cleanup_rate_limits` — and clears every one of them on deactivation. An interrupted update, a migration that copied the database but not the options in a consistent state, or a plugin that cleared schedules on its way out can leave one of those hooks unregistered. It is not a cron failure: cron is running fine, there is simply no event to run. Which is why the fix has nothing to do with cron configuration. WP-CLI — check registration, then re-register ``` # Is the event actually scheduled? wp cron event list --fields=hook,next_run_relative,recurrence | grep woocommerce # Nothing listed? Re-run the activation routine. wp plugin deactivate woocommerce && wp plugin activate woocommerce # Confirm, then force one run to prove it works. wp cron event run woocommerce_cleanup_sessions ``` / Verification ## How do you prove cron is **actually running**? By watching a due event stop being due. Every other check is indirect: the constant tells you about configuration, the notice tells you about concurrency, and neither tells you whether work is getting done. Run [wp cron event list](https://developer.wordpress.org/cli/commands/cron/event/list/) and note the next run time of an event that is overdue. Wait past the point where your system cron should have fired, list again, and compare. If the timestamp moved, the pipeline works end to end — constants, triggers and all. If it did not move, you have isolated the problem to the trigger, and the warnings above become useful as clues rather than as verdicts. WordPress's [Plugin Handbook chapter on cron](https://developer.wordpress.org/plugins/cron/) documents the scheduling side of that loop. > A warning describes your configuration. Only a due event that stops being due proves anything actually runs. — the only cron test that matters /Footnotes ¹ The DISABLE\_WP\_CRON and ALTERNATE\_WP\_CRON strings are emitted by `wp cron test` — see the [WP-CLI command reference](https://developer.wordpress.org/cli/commands/cron/test/). ² Notice text and the concurrency gate are in [ActionScheduler\_ListTable](https://github.com/woocommerce/action-scheduler/blob/trunk/classes/ActionScheduler_ListTable.php), verified against Action Scheduler 3.9.3 as bundled with WooCommerce 10.8.1. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"DISABLE_WP_CRON Is Set to True: WP-Cron Spawning Fix","description":"The DISABLE_WP_CRON constant is set to true, WP-Cron spawning is disabled, and Maximum simultaneous queues already in progress — what each warning means.","datePublished":"2026-08-03","dateModified":"2026-08-03","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/disable-wp-cron-warnings/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/og_image.jpg","width":1200,"height":630,"caption":"DISABLE_WP_CRON Is Set to True: WP-Cron Spawning Fix"},"keywords":["disable wp cron","wp cron spawning is disabled","wp cron test","alternate wp cron","maximum simultaneous queues already in progress","action scheduler queue stuck","woocommerce daily cron not scheduled","wordpress cron warning"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"DISABLE_WP_CRON Is Set to True: WP-Cron Spawning Fix","item":"https://wpwebhooks.org/blog/disable-wp-cron-warnings/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What does \"The DISABLE_WP_CRON constant is set to true. WP-Cron spawning is disabled.\" mean?","acceptedAnswer":{"@type":"Answer","text":"It is an error from the WP-CLI command wp cron test. That command tries to spawn WP-Cron over HTTP, and it refuses to even try when DISABLE_WP_CRON is true, because the constant is what stops WordPress triggering wp-cron.php on page loads. It reports the configuration, not a fault."}},{"@type":"Question","name":"Is DISABLE_WP_CRON bad?","acceptedAnswer":{"@type":"Answer","text":"No, on its own it is a deliberate optimisation. It stops every page load checking for due events, which is the recommended setup on busy sites. It only becomes a fault when nothing replaces the trigger, so the test is whether a real system cron entry, or a host-level scheduler, is hitting wp-cron.php on a schedule."}},{"@type":"Question","name":"What does \"Maximum simultaneous queues already in progress (1 queue)\" mean?","acceptedAnswer":{"@type":"Answer","text":"It is an Action Scheduler admin notice, shown when the number of open claims has reached the concurrency limit. That limit defaults to 1 and is filtered by action_scheduler_queue_runner_concurrent_batches, so one running batch is enough to trigger the notice. It is informational unless the queue stops draining."}},{"@type":"Question","name":"How do I fix an Action Scheduler queue that is stuck at maximum simultaneous queues?","acceptedAnswer":{"@type":"Answer","text":"Find out whether a batch is genuinely running or a claim is stale. Look for in-progress actions on the Scheduled Actions screen that have not moved for far longer than one batch should take, cancel or re-run them, then process the backlog with wp action-scheduler run. Raising the concurrency filter treats the symptom, not the stall."}},{"@type":"Question","name":"Why is a WooCommerce daily event missing from the schedule?","acceptedAnswer":{"@type":"Answer","text":"Because scheduled events are registered on activation and cleared on deactivation, so a plugin update, a migration or an interrupted install can leave a hook such as woocommerce_cleanup_sessions unscheduled. Check it with wp cron event list, and re-register by deactivating and reactivating the plugin."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/disable-wp-cron-warnings.png","caption":"FIG 01 — Which cron warning you are looking at, and what causes it","description":"Three different warnings point at three different layers. The DISABLE_WP_CRON constant is set to true comes from WP-CLI refusing to spawn WP-Cron over HTTP, and is only a real fault when no system cron replaces it. The ALTERNATE_WP_CRON warning means cron runs are redirect-driven rather than asynchronous. Maximum simultaneous queues already in progress comes from Action Scheduler, and means the claim count has reached the concurrency limit, usually because a stale claim is holding the only slot. A missing WooCommerce daily event is a scheduling problem rather than a running problem, fixed by re-registering the event.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "woocommerce_checkout_order_created Hook: Docs & Timing" description: "woocommerce_checkout_order_created documentation: the signature, where it sits in the checkout hook sequence, and why it never fires on the blocks checkout." url: "https://wpwebhooks.org/blog/woocommerce-checkout-order-created/" date: "2026-07-31" --- # woocommerce_checkout_order_created Hook: Docs & Timing **TL;DR:** `woocommerce_checkout_order_created` fires once the order row exists — and only on the shortcode checkout. - Signature: `do_action( 'woocommerce_checkout_order_created', $order )` — one parameter, a `WC_Order`. Added in WooCommerce 4.3.0. - It runs inside `WC_Checkout::create_order()`, immediately after `$order->save()` and after the order meta hook. - The blocks checkout never reaches that method, so it fires `woocommerce_store_api_checkout_order_created` instead. - It also misses admin-created, API-created and subscription renewal orders. - If you need _every_ order, bind to a status transition, not to a checkout hook. / Overview ## What is **woocommerce\_checkout\_order\_created**? It is the action WooCommerce fires the moment a checkout order has been written to the database. It lives at the end of `WC_Checkout::create_order()` in [class-wc-checkout.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/class-wc-checkout.php), carries an `@since 4.3.0` tag, and is the earliest point in the checkout at which you are holding a saved order with a real ID. That last part is what makes it useful. Its neighbour `woocommerce_checkout_create_order` runs a few lines earlier and gives you the order before it is saved, which is the right place to change it and the wrong place to tell anything about it — the order has no ID yet, and an exception thrown later can still discard it entirely. / Signature ## What is the exact **signature**? One parameter. That is the whole surface: PHP — class-wc-checkout.php ``` /** * Action hook fired after an order is created. * * @since 4.3.0 */ do_action( 'woocommerce_checkout_order_created', $order ); ``` | Parameter | Type | What it holds | | --- | --- | --- | | `$order` | `WC_Order` | The saved order object, with a real ID, line items, totals and addresses already persisted. | Passing the object rather than an ID is a small convenience with a real consequence: you can read totals and items without a `wc_get_order()` round trip, and you are guaranteed to be looking at the same instance the checkout just saved rather than a re-read that might miss something still in memory. / Sequence ## Where does it sit in the **checkout hook sequence**? Fourth, in a run of hooks that all fire within a few lines of each other. The order is fixed and worth knowing, because picking the wrong neighbour is the most common reason a callback misbehaves: | # | Hook | Parameters | Order saved? | | --- | --- | --- | --- | | 1 | `woocommerce_checkout_create_order` | `$order, $data` | No — modify it here | | 2 | _$order->save()_ | — | The write itself | | 3 | `woocommerce_checkout_update_order_meta` | `$order_id, $data` | Yes | | 4 | `woocommerce_checkout_order_created` | `$order` | Yes | | 5 | `woocommerce_checkout_order_processed` | `$order_id, $posted_data, $order` | Yes — later in the request | FIG 01 — Where the hook sits, and the path that skips it Between 2 and 4 there is a guard worth knowing about. Recent WooCommerce re-reads the persisted order and throws if the cart had items but the saved order has none, so a save that silently dropped every line item aborts the checkout rather than completing a paid-but-empty order. By the time hook 4 runs, that check has already passed. / Neighbours ## What do **woocommerce\_checkout\_create\_order** and **\_line\_item** pass? The order before it is saved, and each line item before it is attached. These are the two hooks people land on this page looking for, so here they are exactly as WooCommerce 10.9 declares them in `class-wc-checkout.php`: PHP — class-wc-checkout.php, both @since 3.0.0 ``` /** * Action hook to adjust order before save. * * @since 3.0.0 */ do_action( 'woocommerce_checkout_create_order', $order, $data ); /** * Action hook to adjust item before save. * * @since 3.0.0 */ do_action( 'woocommerce_checkout_create_order_line_item', $item, $cart_item_key, $values, $order ); ``` | Hook | Parameter | Type | What it holds | | --- | --- | --- | --- | | `woocommerce_checkout_create_order` | `$order` | `WC_Order` | The unsaved order. Addresses, payment method and cart data are set; there is no ID yet. | | | `$data` | `array` | The posted checkout fields, already validated and sanitised: billing\_\*, shipping\_\*, payment\_method, order\_comments. | | `woocommerce_checkout_create_order_line_item` | `$item` | `WC_Order_Item_Product` | The line item, with product, quantity, subtotal and total set, not yet added to the order. | | | `$cart_item_key` | `string` | The cart hash for this item — the key into WC()->cart->get\_cart(). | | | `$values` | `array` | The raw cart item array: product\_id, variation\_id, quantity, line totals, and any custom cart data a plugin added. | | | `$order` | `WC_Order` | The same unsaved order as above. | Both are _modify_ hooks, not _notify_ hooks. `woocommerce_checkout_create_order` is where you set a custom meta value or override a field before the single save that follows; `_line_item` is where cart-level custom data, a gift message or an engraving option, gets copied onto the item so it survives into the order. The `$values` array is the only place that custom cart data still exists at this point, which is why the line-item hook is the standard way to persist it. Neither is a safe place to tell another system anything. The order has no ID, the line items are not attached, and an exception anywhere in the rest of `create_order()` discards all of it. Fire your notification from the hook this page is about, four steps later, once the write has happened. WooCommerce also fires the sibling hooks `_fee_item`, `_shipping_item`, `_tax_item` and `_coupon_item` with the same shape, one per item type, all before the save. / Blocks ## Why does it never fire on the **blocks checkout**? Because the blocks checkout does not call `WC_Checkout::create_order()`. It posts to the Store API, which builds the order through its own route and fires its own parallel actions — `woocommerce_store_api_checkout_order_created` and `woocommerce_store_api_checkout_order_processed`. WooCommerce states the rule plainly in its [hook alternatives documentation](https://developer.woocommerce.com/docs/block-development/reference/hooks/hook-alternatives/): hooks that fire in the shortcode process do not fire on Store API requests from the blocks. This is the single biggest source of "my order hook stopped working" reports, and it usually arrives as a site change rather than a code change — someone swaps the shortcode checkout page for the Checkout block, and an integration that worked for two years goes quiet without a single error. Nothing throws. The callback is simply never called. PHP — cover both checkout paths ``` $notify = function( $order ) { // One callback, both routes. my_enqueue_order_notification( $order->get_id() ); }; add_action( 'woocommerce_checkout_order_created', $notify ); add_action( 'woocommerce_store_api_checkout_order_created', $notify ); ``` > Any other WC hooks that fire in the Shortcode process will not fire on Store API requests from the blocks. — WooCommerce block development documentation try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Duplicates ## Can the same order fire this hook **twice**? Yes, and this one catches almost everybody. `create_order()` does not always create an order. It first looks for an order the session is already awaiting payment on, and if that order still matches the cart — same items, same total, verified through a cart hash — and is still `pending` or `failed`, it resumes that order instead of making a new one. PHP — the resume branch in create\_order() ``` $order_id = absint( WC()->session->get( 'order_awaiting_payment' ) ); $cart_hash = WC()->cart->get_cart_hash(); $order = $order_id ? wc_get_order( $order_id ) : null; if ( $order && $order->has_cart_hash( $cart_hash ) && $order->has_status( [ 'pending', 'failed' ] ) ) { do_action( 'woocommerce_resume_order', $order_id ); $order->remove_order_items(); // re-added below } else { $order = new WC_Order(); } ``` The hook at the end of the method does not know or care which branch ran. So a customer whose card is declined, who then presses pay again, produces two `woocommerce_checkout_order_created` calls carrying **the same order ID** — three attempts, three calls. If your callback posts to a CRM, that is three records for one order — and if it sends an [order SMS](https://wpwebhooks.org/blog/woocommerce-sms-order-notification-twilio/), three billed messages. The defence is a marker on the order rather than a check in your own code path, because the second call happens in a completely separate request: PHP — fire once per order, not once per attempt ``` add_action( 'woocommerce_checkout_order_created', function( $order ) { if ( $order->get_meta( '_my_notified' ) ) { return; // resumed order, already sent } $order->update_meta_data( '_my_notified', time() ); $order->save(); my_enqueue_order_notification( $order->get_id() ); } ); ``` Listening for `woocommerce_resume_order` is the other half of the picture if you want to distinguish a retry from a first attempt rather than just suppress it — it fires only on the resume branch, and only ever with the order ID. / Comparison ## How does it differ from **order\_processed** and **new\_order**? By timing, payload, and how much of the world they cover. All three describe "an order happened", and they disagree about when that is: | Hook | Fires when | Covers blocks? | Covers admin orders? | | --- | --- | --- | --- | | `woocommerce_checkout_order_created` | Order row written, inside create\_order() | No | No | | `woocommerce_checkout_order_processed` | Later in the checkout request, with posted data | No | No | | `woocommerce_new_order` | Any order is first persisted, from any source | Yes | Yes | | `woocommerce_order_status_processing` | Order reaches the processing status | Yes | Yes | So the choice follows from the question you are answering. Enriching a checkout order with metadata is a job for `order_created`, because you want the earliest saved state and you only care about checkout. Reacting to a paid order is a job for a status transition, because payment is what you actually care about and it happens on every path — including [gateway callbacks that arrive minutes later](https://wpwebhooks.org/blog/woocommerce-webhooks-action-scheduler/), long after the checkout request has ended. / Delivery ## What breaks if you send the request **inline**? Checkout gets slower and less reliable, in that order. A blocking HTTP call in this hook runs inside the customer's checkout request, between the order being saved and the payment gateway being handed control. Every millisecond the endpoint takes is a millisecond the buyer spends on a spinner at the most abandonment-sensitive moment of the entire session. Then there is what an exception does here. The hook fires inside a `try` block whose `catch` releases the order's coupons and discards the order. A callback that throws — a JSON encoding failure, a fatal in a third-party client library — does not just fail to notify: it can take down a checkout that had already succeeded. Wrapping your own callback in a `try/catch` is not defensive programming here, it is required. The sound version is to record the intent and return. Write the order ID somewhere durable, let a background worker do the HTTP with retries and a log, and keep the checkout request doing nothing but checkout. That is the same conclusion the [built-in WooCommerce webhooks](https://woocommerce.com/document/webhooks/) reach — they queue deliveries rather than sending them in the request that created the order. /Footnotes ¹ Hook position, `@since` tag and parameters verified in [class-wc-checkout.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/class-wc-checkout.php) against WooCommerce 10.8.1. ² Store API equivalents and the shortcode-only rule from the WooCommerce [hook alternatives](https://developer.woocommerce.com/docs/block-development/reference/hooks/hook-alternatives/) reference. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"woocommerce_checkout_order_created Hook: Docs & Timing","description":"woocommerce_checkout_order_created documentation: the signature, where it sits in the checkout hook sequence, and why it never fires on the blocks checkout.","datePublished":"2026-07-31","dateModified":"2026-09-10","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/woocommerce-checkout-order-created/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/og_image.jpg","width":1200,"height":630,"caption":"woocommerce_checkout_order_created Hook: Docs & Timing"},"keywords":["woocommerce checkout order created","woocommerce order created hook","woocommerce checkout hooks","woocommerce store api checkout order created","woocommerce new order hook","wc checkout create order","woocommerce order hook sequence"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"woocommerce_checkout_order_created Hook: Docs & Timing","item":"https://wpwebhooks.org/blog/woocommerce-checkout-order-created/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is woocommerce_checkout_order_created?","acceptedAnswer":{"@type":"Answer","text":"It is a WooCommerce action that fires inside WC_Checkout::create_order() immediately after the order has been saved to the database. It was introduced in WooCommerce 4.3.0 and receives a single parameter, the WC_Order object, which already has a real order ID at that point."}},{"@type":"Question","name":"What is the signature of woocommerce_checkout_order_created?","acceptedAnswer":{"@type":"Answer","text":"do_action('woocommerce_checkout_order_created', $order) — one parameter, a WC_Order instance. Because it passes the object rather than an ID, a callback can read totals, items and addresses without calling wc_get_order() first."}},{"@type":"Question","name":"Why does woocommerce_checkout_order_created not fire on the blocks checkout?","acceptedAnswer":{"@type":"Answer","text":"Because the blocks checkout does not go through WC_Checkout::create_order() at all. It posts to the Store API, which fires its own parallel actions, woocommerce_store_api_checkout_order_created and woocommerce_store_api_checkout_order_processed. WooCommerce documents that shortcode checkout hooks do not run on Store API requests."}},{"@type":"Question","name":"What is the difference between woocommerce_checkout_order_created and woocommerce_checkout_order_processed?","acceptedAnswer":{"@type":"Answer","text":"Timing and payload. order_created fires inside create_order() the moment the order row exists and passes only $order. order_processed fires later in the same request, after payment has been set up, and passes $order_id, $posted_data and $order. Use created for enrichment, processed when you need the posted checkout data."}},{"@type":"Question","name":"Which hook should trigger an order webhook?","acceptedAnswer":{"@type":"Answer","text":"For a notification that an order exists, woocommerce_checkout_order_created is the earliest safe point, but it misses blocks and admin-created orders. If you need every order regardless of origin, bind to a status transition such as woocommerce_order_status_processing instead, and mirror the Store API hook when you specifically want checkout-time behaviour."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/woocommerce-checkout-order-created.png","caption":"FIG 01 — Where the hook sits, and the path that skips it","description":"On the shortcode checkout, WC_Checkout::create_order builds the order object, fires woocommerce_checkout_create_order before saving, saves the order, fires woocommerce_checkout_update_order_meta, and then fires woocommerce_checkout_order_created with the saved order. Later in the same request woocommerce_checkout_order_processed fires with the order id, the posted data and the order. The blocks checkout goes through the Store API instead and fires the parallel woocommerce_store_api_checkout_order_created and woocommerce_store_api_checkout_order_processed actions, so a callback bound only to the shortcode hook never runs for a blocks order.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "LLM API Pricing 2026: Real Cost Per Call, Compared" description: "LLM API pricing compared across OpenAI, Google, Anthropic and Chinese models like Kimi K3 — with the token math for what one real AI call costs." url: "https://wpwebhooks.org/blog/llm-api-pricing-cost-per-call/" date: "2026-07-29" --- # LLM API Pricing 2026: Real Cost Per Call, Compared **TL;DR:** at a realistic production request size of ~4,700 input and ~390 output tokens, one AI API call costs anywhere from **$0.0004 to $0.035** across the standard model tiers — a 90× spread driven purely by model choice, rising to $0.21 on a pro tier. - Output tokens cost **5–8×** input tokens almost everywhere, so response length moves your bill more than prompt length. - "Chinese model" does not mean "cheap". Kimi K3 lists at $3 / $15 per million — the same per-call cost as Claude Sonnet 5 at standard pricing. - Prices move in both directions. Since this article first ran, Google halved Gemini 3.6 Flash and DeepSeek roughly tripled V4 Flash — the [change log below](#price-changes) tracks both. - If you resell AI as credits, charge from _actual_ token usage. A flat price per request always undercharges your largest requests. / The unit ## What does a single **AI API call** actually cost? Multiply your input tokens by the model's input rate, your output tokens by its output rate, and add them. That is the whole formula — every provider on this page bills per million tokens in exactly that shape: `cost = (input_tokens × input_rate + output_tokens × output_rate) / 1,000,000` Worked through on Gemini 3.6 Flash, which lists at $0.75 per million input and $3.75 per million output on its promotional rate, for a call of 4,700 input and 390 output tokens: - input — 4,700 × $0.75 ÷ 1,000,000 = **$0.00353** - output — 390 × $3.75 ÷ 1,000,000 = **$0.00146** - total — **$0.00499**, the $0.0050 you will see in the table below Ten thousand of those calls is $49.88. A million is $4,988. That is the entire economics of an AI feature, and it is worth doing this arithmetic once by hand before trusting any dashboard. If you would rather drag a slider than reach for a calculator, every figure on this page is live in the [LLM API cost calculator](https://wpwebhooks.org/tools/llm-cost-calculator/). What makes the number hard to predict is not the arithmetic, it is knowing your real token size. Measured request sizes are almost always different from the ones people assume when they estimate. Every figure on this page uses one fixed reference call — **4,700 input tokens and 390 output tokens** — which is typical of a planning or tool-calling request: a substantial system prompt plus tool schemas going in, a short structured decision coming back. Scale the results linearly for your own workload, and expect real traffic to run 1.5–2× larger than a thin test sample. That asymmetry — a fat prompt and a thin response — matters, because it is the opposite of a chat workload. If your calls generate long prose answers instead, the output-rate column below dominates and the ranking changes. / The majors ## How much do the major providers charge **per million tokens**? Here are the current published rates for the standard (non-batch, non-cached) tiers at each of the three largest Western providers, with the per-call cost worked out at the reference size above. Premium and pro tiers are a separate table further down. All rates verified against provider documentation on 21 August 2026.¹ | Provider | Model | Input / 1M | Output / 1M | Per call | | --- | --- | --- | --- | --- | | OpenAI | `gpt-5-nano` | $0.05 | $0.40 | $0.0004 | | OpenAI | `gpt-5.6-luna` | $0.20 | $1.20 | $0.0014 | | OpenAI | `gpt-5.4-nano` | $0.20 | $1.25 | $0.0014 | | OpenAI | `gpt-5-mini` | $0.25 | $2.00 | $0.0020 | | OpenAI | `gpt-5.4-mini` | $0.75 | $4.50 | $0.0053 | | OpenAI | `gpt-5` / `gpt-5.1` | $1.25 | $10.00 | $0.0098 | | OpenAI | `gpt-5.2` | $1.75 | $14.00 | $0.0137 | | OpenAI | `gpt-5.6-terra` | $2.00 | $12.00 | $0.0141 | | OpenAI | `gpt-5.4` | $2.50 | $15.00 | $0.0176 | | OpenAI | `gpt-5.6-sol` | $4.00 | $20.00 | $0.0266 | | OpenAI | `gpt-5.5` | $5.00 | $30.00 | $0.0352 | | Google | `Gemini 3.1 Flash-Lite` | $0.25 | $1.50 | $0.0018 | | Google | `Gemini 3.5 Flash-Lite` | $0.30 | $2.50 | $0.0024 | | Google | `Gemini 3.6` / `3.7` / `3.8 Flash` (promo) | $0.75 | $3.75 | $0.0050 | | Google | `Gemini 3.6` / `3.7` / `3.8 Flash` (from 1 Jan 2027) | $1.50 | $7.50 | $0.0100 | | Google | `Gemini 3.5 Flash` | $1.50 | $9.00 | $0.0106 | | Google | `Gemini 3.1 Pro` (preview) | $2.00 | $12.00 | $0.0141 | | Anthropic | `Claude Haiku 4.5` | $1.00 | $5.00 | $0.0067 | | Anthropic | `Claude Sonnet 5` | $2.00 | $10.00 | $0.0133 | | Anthropic | `Claude Opus 5` | $5.00 | $25.00 | $0.0333 | Three things stand out. First, the budget tier is genuinely cheap: `gpt-5-nano` at $0.0004 per call is 13× cheaper than Gemini 3.6 Flash and 85× cheaper than Opus 5, for a task that may not need frontier reasoning at all. Second, the "flash" and "mini" labels are not a reliable guide to price. Gemini 3.5 Flash costs $0.0106 per call — more than Claude Haiku 4.5 at $0.0067, and 5× more than `gpt-5-mini`, despite all three sitting in the same nominal tier. Third, and least intuitive: a higher generation number does not mean a higher price. `gpt-5.6-luna` costs $0.0014 per call, roughly a tenth of the older `gpt-5.2` at $0.0137 — while `gpt-5.4-mini` at $0.0053 costs 2.7× its `gpt-5-mini` predecessor. Within a single vendor's line-up the suffix is a product-positioning label, not a rung on a price ladder. The only reliable ordering is the one you compute yourself. Note the moving targets in that table. [Anthropic's published pricing](https://platform.claude.com/docs/en/about-claude/pricing) billed Claude Sonnet 5's $2 / $10 as introductory through 31 August 2026, with $3 / $15 to follow — then cancelled the increase on 1 September and made $2 / $10 the standard rate, so that row is no longer a trap. [Google's Gemini pricing](https://ai.google.dev/gemini-api/docs/pricing) still is: 3.6, 3.7 and the new 3.8 Flash hold $0.75 / $3.75 only through 31 December 2026 and double to $1.50 / $7.50 on 1 January 2027. OpenAI has joined in, calling `gpt-5.6-sol`'s $4 / $20 promotional "at least through November 21, 2026" with no successor rate published. Budget against an intro rate and your per-call cost rises on a date you did not choose. / Premium tiers ## What about the **premium and pro** models? They exist, and they are priced in a different league. The table above deliberately covers the standard tiers — the models you would realistically put behind a per-request product feature. Each provider also publishes a premium line above them: | Provider | Model | Input / 1M | Output / 1M | Per call | | --- | --- | --- | --- | --- | | Anthropic | `Claude Fable 5` / `Fable 5.1` | $10.00 | $50.00 | $0.0665 | | Anthropic | `Claude Mythos 5` / `Mythos 5.1` (limited availability) | $10.00 | $50.00 | $0.0665 | | OpenAI | `gpt-6-astra` | $10.00 | $50.00 | $0.0665 | | OpenAI | `gpt-5-pro` | $15.00 | $120.00 | $0.1173 | | OpenAI | `gpt-5.2-pro` | $21.00 | $168.00 | $0.1642 | | OpenAI | `gpt-5.4-pro` / `gpt-5.5-pro` | $30.00 | $180.00 | $0.2112 | Read those per-call figures against the standard table and the gap is stark: $0.2112 ÷ $0.0004 means `gpt-5.5-pro` costs **540×** what `gpt-5-nano` costs for the same request, and $0.2112 ÷ $0.0050 puts it at 42× Gemini 3.6 Flash. Ten thousand calls: $4 on nano, $50 on Flash, $2,112 on pro. Anthropic's Fable line — Fable 5 and the newer Fable 5.1 — and OpenAI's new `gpt-6-astra` all sit lower, at $0.0665: double Opus 5 and about twice the top of the standard table. Nothing here says these models are overpriced; a task that genuinely needs them is a task the cheap tiers will fail at, and one correct answer beats twenty wrong ones. But they are a poor fit for a metered, per-request feature — at $0.21 a call, a thousand requests is $211, which reprices your product rather than your infrastructure. Reserve them for low-volume, high-stakes steps and route the routine work elsewhere. / Chinese models ## Are **Chinese models** the cheap option — and where does Kimi K3 land? Some are dramatically cheaper. Kimi K3 is not one of them. The assumption that a Chinese lab automatically means budget pricing breaks down as soon as you read the published rate cards side by side: | Provider | Model | Input / 1M | Cached input | Output / 1M | Per call | | --- | --- | --- | --- | --- | --- | | Moonshot | `Kimi K3` | $3.00 | $0.30 | $15.00 | $0.0200 | | Moonshot | `Kimi K2.6` | $0.95 | $0.16 | $4.00 | $0.0060 | | DeepSeek | `deepseek-flash` (was V4 Flash) | $0.30 | $0.006 | $1.20 | $0.0019 | | DeepSeek | `deepseek-v4-pro` | $1.32 | $0.044 | $3.96 | $0.0077 | | Z.ai | `GLM-4.7-FlashX` | $0.07 | $0.01 | $0.40 | $0.0005 | | Z.ai | `GLM-5.3-Flash` | $0.15 | $0.03 | $0.50 | $0.0009 | | Z.ai | `GLM-4.7` | $0.60 | $0.11 | $2.20 | $0.0037 | | Z.ai | `GLM-5.2` / `GLM-5.3` | $1.40 | $0.26 | $4.40 | $0.0083 | [Moonshot prices Kimi K3](https://platform.kimi.ai/docs/pricing/chat-k3) at $3.00 per million input tokens on a cache miss, $0.30 on a cache hit, and $15.00 per million output tokens, across a 1,048,576-token context window. At the reference call size that is (4,700 × $3 + 390 × $15) ÷ 1,000,000 = $0.01995 — _half as much again_ as Claude Sonnet 5 at $0.0133, and four times Gemini 3.6 Flash at its promotional rate. Against deepseek-flash's $0.00188, that is 10.6× the cost of the same call: 10,000 requests is $200 on K3 and $18.78 on DeepSeek. Moonshot's own budget option, Kimi K2.6 at $0.95 / $4.00, lands at $0.0060. K3 is positioned as a frontier model and priced like one. > Kimi K3 costs half as much again as Claude Sonnet 5 per call. GLM-5.3-Flash costs 22× less than K3. "Chinese model" is not a pricing tier. — per-call arithmetic at 4,700 in / 390 out At the other end, [DeepSeek](https://api-docs.deepseek.com/quick_start/pricing/) has repriced again: V4 Flash is now listed as `deepseek-flash` at $0.30 input / $1.20 output at its peak rate, which works out to $0.0019 per call — down from the $0.0026 it charged in August, though still above the $0.0008 of July. DeepSeek is also the one provider here that prices by the clock: those are peak rates, charged 01:00–04:00 and 06:00–10:00 UTC on weekdays; every other hour, weekends included, bills at half. Its cache-hit input rate of $0.006 per million is the most aggressive discount on this page, 50× below its own cache-miss rate. [Z.ai's GLM line](https://docs.z.ai/guides/overview/pricing) now spans the budget range on its own, from GLM-4.7-FlashX at $0.07 / $0.40 and the new GLM-5.3-Flash at $0.15 / $0.50 up to GLM-5.3 at $1.40 / $4.40, with GLM-4.7-Flash listed at no cost at all. The practical lesson is that provider nationality tells you nothing about price. Read the rate card, compute your own per-call number, and rank by that. ![Cyberpunk illustration titled “What an AI Feature Actually Costs Per Call”: over a rainy neon city, a green holographic HUD shows COST = (input_tokens × input_rate + output_tokens × output_rate) / 1,000,000, with an INPUT column reading input_tokens = 500 and input_rate = $10 / 1M and an OUTPUT column reading output_tokens = 200 and output_rate = $30 / 1M, feeding boxes labelled INPUT COST $0.0050 and OUTPUT COST $0.0060 into a SUM box and a final SUM to TOTAL box reading $0.0110. A chrome-and-neon android woman looks on in profile.](https://wpwebhooks.org/blog/llm-api-pricing-cost-per-call/og_image.jpg) / Output weight ## Why does **output pricing** dominate the bill? Because every provider charges several times more for tokens the model writes than for tokens you send. The ratio is remarkably consistent: - **6–8×** — OpenAI: 8× across `gpt-5`, `gpt-5.1` and `gpt-5.2`, and 5–6× on the newer `gpt-5.4` and `gpt-5.6` models. - **5×** — Anthropic across Haiku, Sonnet and Opus, Google's Gemini 3.6 and 3.7 Flash, and Moonshot's Kimi K3. - **3–4×** — DeepSeek: 3× on V4 Pro, 4× on the renamed deepseek-flash. - **3.1–3.3×** — Z.ai's GLM-5.2, GLM-5.3 and GLM-5.3-Flash, the flattest ratios of the group. Run the reference call through that lens. Output is 390 of 5,090 total tokens — **7.7%** of the volume. But on Gemini 3.6 Flash it is $0.00146 of a $0.00499 bill, or **29%** of the money. Those 390 tokens cost nearly four times what their share of the token count suggests. Double the response to 780 tokens and the call goes to $0.00645, up 29%; double the _prompt_ to 9,400 tokens instead and it goes to $0.00851, up 71% — but you had to add 4,700 tokens to do it, twelve times as many as the output change. Trimming a verbose system prompt feels productive; capping `max_output_tokens` and asking for terse structured output usually saves more. Reasoning models complicate this further, because reasoning tokens are billed as output. A model that always thinks before answering can spend more on the invisible trace than on the visible reply — the same behaviour that makes reasoning models good at planning tasks is what makes their bills volatile. If you route a planning step to a reasoning model, measure the output-token distribution, not just the mean. / Caching ## How much does **prompt caching** change the math? Substantially, if your prompt has a large stable prefix. Cached input is billed at a fraction of the normal input rate, so a long system prompt and tool schema block — exactly the shape of an agent request — can be reused across calls almost for free. | Provider | Cache-hit input rate | Relative to base input | | --- | --- | --- | | OpenAI (`gpt-5-nano` – `gpt-6-astra`) | $0.005 – $1.00 / 1M | 0.1× | | Google (Gemini 3.x) | $0.025 – $0.20 / 1M | 0.1× | | Anthropic | $0.10 – $1.00 / 1M | 0.1× (0.025× on Fable 5.1 and Mythos 5.1) | | Moonshot (Kimi K2.6 / K3) | $0.16 – $0.30 / 1M | 0.1–0.17× | | Z.ai (GLM line) | $0.01 – $0.26 / 1M | 0.15–0.2× | | DeepSeek | $0.006 – $0.044 / 1M | 0.02–0.03× | Anthropic also charges a write premium — 1.25× base input for a five-minute cache, 2× for a one-hour cache — which means caching pays for itself after a single read on the short TTL, or two reads on the long one. Work it through on Sonnet 5 ($2 in / $10 out, cache hits at $0.20), assuming 4,000 of the 4,700 input tokens are a cacheable prefix: - cached input — 4,000 × $0.20 ÷ 1,000,000 = $0.0008 - fresh input — 700 × $2.00 ÷ 1,000,000 = $0.0014 - output — 390 × $10.00 ÷ 1,000,000 = $0.0039 - total — **$0.0061**, against $0.0133 uncached That is a **54% cut** with no quality trade-off — better than dropping from Sonnet 5 to Haiku 4.5 ($0.0067), and you keep the stronger model. Batch APIs are the other lever: [Anthropic](https://platform.claude.com/docs/en/about-claude/pricing) and [OpenAI](https://developers.openai.com/api/docs/pricing) both discount asynchronous batch processing by 50%, and [Google](https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing) does the same. If the work is not interactive — nightly enrichment, bulk classification, backfills — half your bill is available for the cost of queueing the job. That is a natural fit for any system that already runs work through a [background job queue](https://wpwebhooks.org/blog/wordpress-job-queue/). / Reselling ## What goes wrong when you **resell AI calls** as flat-price credits? You lose money on exactly the requests that cost you the most. A flat "one request = N credits" price is a fixed charge over a variable cost, so it overcharges small calls and undercharges large ones — and large calls are precisely what heavy users make. Work the numbers on a hypothetical plan: a $249/year tier with a 5,000-credit monthly pool, charging 7 credits per request. That pool buys 8,571 requests a year. At $0.010 per call the provider bill is $86; at a realistic build size of $0.015 it is $129. Comfortable. Now scale the same structure up to a $649/year tier with a 30,000-credit pool: 51,429 requests a year, costing $514 to $771. The margin has narrowed from 65% to somewhere between 21% and a loss, without anything changing except the size of the pool. The leak is not the pool size, it is that the charge does not move with the request. A user whose prompts are three times larger than average pays exactly the same as one whose prompts are tiny. | Concern | Flat price per request | Charge from actual tokens | | --- | --- | --- | | Large requests | Undercharged — the loss tail | Cost proportionally more credits | | Small requests | Overcharged — feels unfair | Cost less, down to a floor | | Margin per credit | Varies with request size | Constant regardless of mix | | Model swap | Requires re-pricing every tier | Only the $/credit backing changes | | Failed provider call | Pre-charged, needs a refund path | Charged after success — nothing to refund | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Token-linked credits ## How do you make credits **track actual cost**? Compute the charge from the token counts the provider returns, using a weight that mirrors that model's own output-to-input price ratio. Two constants per tier are enough: PHP — token-linked credit charge ``` // Weight output tokens by the model's own output/input price ratio. // Gemini 3.6 Flash: $0.75 in / $3.75 out -> weight 5. $weighted = $usage['input_tokens'] + 5 * $usage['output_tokens']; // 1 credit = 1,000 weighted tokens, minimum 1 credit per call. $credits = max( 1, (int) ceil( $weighted / 1000 ) ); // Reference call: 4700 + 5 * 390 = 6650 weighted -> 7 credits. ``` The weight is the whole trick. When `output_weight` equals the provider's output/input price ratio, one credit maps to a _fixed_ dollar amount of provider cost for any input/output mix. On Gemini 3.6 Flash at weight 5 and 1,000 weighted tokens per credit, one credit is always about $0.00075 of API spend — whether the call was a fat prompt with a one-word answer or a short prompt with a long essay. FIG 01 — Charging credits from actual token usage Two details make this safe in production. Charge _after_ the provider call, not before: a failed request then costs the user nothing and there is no refund path to build. And gate on balance before the call rather than trying to predict the charge — check that the remaining pool is above zero, let the request through, then settle the real cost. The maximum you can overshoot is bounded by your own input and output caps, so a hard `max_output_tokens` is what keeps the tail finite. A model with a different ratio just needs a different weight. Swapping to a provider whose output costs 8× input means weight 8; swapping to DeepSeek V4 Pro means weight 3, to deepseek-flash weight 4. The [cost calculator](https://wpwebhooks.org/tools/llm-cost-calculator/) works the credit charge out for any model and token size, so you can see what one credit is actually backing before you publish a price. Because credits are charged from real usage, changing models changes what a credit costs you, not whether the pricing structure still holds. / In production ## What should you actually **measure**? Per-call token counts, from day one. Almost every cost surprise traces back to a team that estimated token size instead of logging it. Concretely: 1. **Log `input_tokens` and `output_tokens` on every call**, alongside the model name. Without the model name, a later price change makes the history unreadable. 2. **Watch the distribution, not the mean.** The 95th-percentile request is what determines your worst case; a mean hides the reasoning traces that ran ten times longer than usual. 3. **Bound the tail explicitly.** Cap input length and set `max_output_tokens`. Those two limits are the only hard ceiling on what one call can cost you. 4. **Model the 100%-utilisation case.** If every customer consumed every credit they paid for, would each tier still be profitable? Typical usage is far below that, but the ceiling is what a single heavy account can reach. 5. **Re-check rate cards quarterly.** Introductory pricing expires, new tiers land below old ones, and a model you chose on price six months ago may no longer be the cheap option. / Price changes ## What has actually **changed** since this article ran? Enough to matter, in six weeks. This is the log of every rate movement we have re-verified against provider documentation since first publishing on 29 July 2026 — recorded on the day we caught it, not the day the provider changed it. The percentage is the move in the cost of the reference call, not in the headline rate. | Provider | Model | Was | Now | Per-call change | | --- | --- | --- | --- | --- | | Anthropic | `Claude Sonnet 5` | $3.00 / $15.00 | $2.00 / $10.00 | \-33% | | Anthropic | `Claude Fable 5.1` | — | $10.00 / $50.00 | new | | Anthropic | `Claude Mythos 5.1` | — | $10.00 / $50.00 | new | | OpenAI | `gpt-6-astra` | — | $10.00 / $50.00 | new | | Google | `Gemini 3.8 Flash` | — | $0.75 / $3.75 | new | | DeepSeek | `deepseek-flash (was deepseek-v4-flash)` | $0.44 / $1.32 | $0.30 / $1.20 | \-27% | | Z.ai | `GLM-5.3-Flash` | — | $0.15 / $0.50 | new | | Moonshot | `Kimi K2.6` | — | $0.95 / $4.00 | new | | OpenAI | `gpt-5.6-luna` | $1.00 / $6.00 | $0.20 / $1.20 | \-80% | | OpenAI | `gpt-5.6-terra` | $2.50 / $15.00 | $2.00 / $12.00 | \-20% | | OpenAI | `gpt-5.6-sol` | $5.00 / $30.00 | $4.00 / $20.00 | \-24% | | Google | `Gemini 3.6 Flash` | $1.50 / $7.50 | $0.75 / $3.75 | \-50% | | Google | `Gemini 3.7 Flash` | — | $0.75 / $3.75 | new | | DeepSeek | `deepseek-v4-flash` | $0.14 / $0.28 | $0.44 / $1.32 | +237% | | DeepSeek | `deepseek-v4-pro` | $0.435 / $0.87 | $1.32 / $3.96 | +225% | | Z.ai | `GLM-5.3` | — | $1.40 / $4.40 | new | Three of those deserve reading twice. **Anthropic cancelled the Sonnet 5 increase**: the $2 / $10 rate billed as introductory through 31 August is now the standard price, and the $3 / $15 successor this article budgeted against never arrived — the first entry in the log where a published schedule moved the price _down_ relative to plan. **DeepSeek keeps moving**: after tripling V4 Flash into peak and off-peak tiers in August, it renamed the model deepseek-flash and cut it to $0.30 / $1.20 in September. Anyone who picked it in July at $0.0008 a call, watched it hit $0.0026, and is now at $0.0019 has had three different bills for the same code. And **Google halved Gemini 3.6 Flash** in August, then launched 3.7 and 3.8 Flash at the same rate — all promotional through 31 December 2026 and doubling on 1 January 2027 — while newly publishing a flat cache-read rate for every Gemini model, which is why the calculator's cache slider now works there too. Smaller moves: `gpt-6-astra` and Claude Fable 5.1 arrived at the $10 / $50 tier, GLM-5.3-Flash arrived at $0.15 / $0.50, and GLM-5-Turbo dropped off Z.ai's rate card and off the calculator with it. That is the practical argument for logging the model name alongside token counts, as item 1 above says. A price change is invisible in your own telemetry unless you can join spend to model and date. If you want an automated tripwire rather than a calendar reminder, the open datasets at [simonw/llm-prices](https://github.com/simonw/llm-prices) and [pydantic/genai-prices](https://github.com/pydantic/genai-prices) both carry dated historical rates you can diff on a schedule — then confirm any hit against the provider's own page before you act on it. None of this requires a sophisticated billing system. It requires that the number you charge and the number you are charged come from the same measurement. Once those are linked, model selection becomes a quality decision with a known cost consequence — which is the position you want to be in before you wire an AI step into an [agent architecture](https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/) that runs on live traffic. /Footnotes ¹ All rates re-read from provider documentation on 13 September 2026, first published 29 July 2026: [OpenAI API pricing](https://developers.openai.com/api/docs/pricing), [Gemini API pricing](https://ai.google.dev/gemini-api/docs/pricing), [Claude pricing](https://platform.claude.com/docs/en/about-claude/pricing), [Kimi K3 pricing](https://platform.kimi.ai/docs/pricing/chat-k3), [DeepSeek pricing](https://api-docs.deepseek.com/quick_start/pricing/), and [Z.ai GLM pricing](https://docs.z.ai/guides/overview/pricing). Per-call figures are computed at 4,700 input / 390 output tokens and rounded to four decimal places. Published rates change frequently — verify before budgeting. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"LLM API Pricing 2026: Real Cost Per Call, Compared","description":"LLM API pricing compared across OpenAI, Google, Anthropic and Chinese models like Kimi K3 — with the token math for what one real AI call costs.","datePublished":"2026-07-29","dateModified":"2026-09-13","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/llm-api-pricing-cost-per-call/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/llm-api-pricing-cost-per-call/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration titled “What an AI Feature Actually Costs Per Call”: over a rainy neon city, a green holographic HUD shows COST = (input_tokens × input_rate + output_tokens × output_rate) / 1,000,000, with an INPUT column reading input_tokens = 500 and input_rate = $10 / 1M and an OUTPUT column reading output_tokens = 200 and output_rate = $30 / 1M, feeding boxes labelled INPUT COST $0.0050 and OUTPUT COST $0.0060 into a SUM box and a final SUM to TOTAL box reading $0.0110. A chrome-and-neon android woman looks on in profile."},"keywords":["llm api pricing","ai api cost per call","gpt 5 pricing","gemini flash pricing","claude sonnet 5 pricing","kimi k3 pricing","deepseek api pricing","ai credits pricing","token cost calculation","llm cost comparison"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"LLM API Pricing 2026: Real Cost Per Call, Compared","item":"https://wpwebhooks.org/blog/llm-api-pricing-cost-per-call/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How much does one LLM API call cost?","acceptedAnswer":{"@type":"Answer","text":"At a realistic size of 4,700 input and 390 output tokens, between $0.0004 and $0.035 depending on the model. gpt-5-nano is about $0.0004, Gemini 3.6 Flash about $0.0050 at its promotional rate, Claude Sonnet 5 about $0.0133, and Claude Opus 5 about $0.0333. Multiply your own token counts by the published per-million rates to get an exact figure."}},{"@type":"Question","name":"Is Kimi K3 cheaper than Western models?","acceptedAnswer":{"@type":"Answer","text":"No. Moonshot lists Kimi K3 at $3.00 per million input tokens on a cache miss and $15.00 per million output tokens, which comes to 50% more than Claude Sonnet 5 ($2.00 / $10.00) per call and four times Gemini 3.6 Flash at its promotional rate. Its cache-hit input rate is $0.30 per million. K3 is priced as a frontier model, not a budget one."}},{"@type":"Question","name":"Which AI model API is cheapest right now?","acceptedAnswer":{"@type":"Answer","text":"Among the models compared here, gpt-5-nano at $0.05 / $0.40 per million and GLM-4.7-FlashX at $0.07 / $0.40 are the cheapest paid options, followed by GLM-5.3-Flash at $0.15 / $0.50, GLM-4.5-Air at $0.20 / $1.10 and gpt-5.6-luna at $0.20 / $1.20. The DeepSeek Flash model left that group when it repriced to $0.44 / $1.32 in August 2026 and came back to its edge in September as deepseek-flash at $0.30 / $1.20. Cheapest is only useful if quality holds for your task, so A/B a candidate model on real requests before switching."}},{"@type":"Question","name":"Why are output tokens more expensive than input tokens?","acceptedAnswer":{"@type":"Answer","text":"Generating tokens is sequential and compute-bound, while input can be processed in parallel, and providers price accordingly. The ratio is typically 5x on Anthropic, Google Gemini Flash and Kimi K3, 6-8x across the OpenAI GPT-5 line, and 3-4x on DeepSeek. Reasoning tokens are billed as output, so a model that always thinks can cost more than its visible answer suggests."}},{"@type":"Question","name":"How should I price AI credits so I do not lose money?","acceptedAnswer":{"@type":"Answer","text":"Charge credits computed from the token counts the provider returns, not a flat price per request. Weight output tokens by the model output-to-input price ratio, divide the weighted total by a fixed tokens-per-credit constant, and settle after the call succeeds. That keeps the dollar cost behind one credit constant for any request size."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/llm-api-cost-per-call.png","caption":"FIG 01 — Charging credits from actual token usage","description":"A request first hits a balance gate that blocks it when the remaining pool and top-up are exhausted. Allowed requests go to the model provider, which returns the completion together with the actual input and output token counts. Those counts are converted into credits using a weighted formula that prices output tokens higher than input tokens, and only then is the balance settled. Because the charge is computed after the call, a provider error costs the caller nothing and a large request costs proportionally more credits.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "action_scheduler_run_queue: Queue Runner Hook Docs" description: "action_scheduler_run_queue is the WP-Cron hook behind every Action Scheduler queue pass: how it is registered, its every_minute schedule, and how to trigger it." url: "https://wpwebhooks.org/blog/action-scheduler-run-queue-hook/" date: "2026-07-26" --- # action_scheduler_run_queue: Queue Runner Hook Docs **TL;DR:** `action_scheduler_run_queue` is the WP-Cron hook that starts every Action Scheduler queue pass. - Action Scheduler registers it as a recurring event on its own `every_minute` schedule (a 60-second interval it adds itself). - When the hook fires, `ActionScheduler_QueueRunner::run()` executes with the context string `WP Cron`. - One pass claims a batch of 25 actions and runs for at most 30 seconds, then may fire an async loopback request to keep draining. - The hook being scheduled is not the same as the hook firing — if WP-Cron never runs, neither does the queue. / Definition ## What is **action\_scheduler\_run\_queue**? It is the name of the WordPress cron hook that Action Scheduler schedules for itself, and the entry point for all queue processing. Nothing in [Action Scheduler](https://actionscheduler.org/) runs on a timer of its own: the library leans on [WP-Cron](https://developer.wordpress.org/plugins/cron/) for its heartbeat, and `action_scheduler_run_queue` is the hook that heartbeat calls. That makes it the single most useful symbol to know when a queue is stuck. If scheduled actions are piling up as `pending` past their due time, the question is almost always "is `action_scheduler_run_queue` firing?" — not "is the action broken?". FIG 01 — How action\_scheduler\_run\_queue starts a queue pass / Registration ## How is the hook **registered and scheduled**? In `ActionScheduler_QueueRunner`, as two class constants and an `init()` method that wires them up. The hook name and its schedule are fixed in the source: PHP — ActionScheduler\_QueueRunner (simplified) ``` const WP_CRON_HOOK = 'action_scheduler_run_queue'; const WP_CRON_SCHEDULE = 'every_minute'; public function init() { // Add the custom 60-second interval to WP-Cron's schedules. add_filter( 'cron_schedules', [ $this, 'add_wp_cron_schedule' ] ); $cron_context = [ 'WP Cron' ]; if ( ! wp_next_scheduled( self::WP_CRON_HOOK, $cron_context ) ) { wp_schedule_event( time(), self::WP_CRON_SCHEDULE, self::WP_CRON_HOOK, $cron_context ); } add_action( self::WP_CRON_HOOK, [ self::instance(), 'run' ] ); } ``` Three things are worth pulling out. The `every_minute` schedule is not a WordPress built-in — Action Scheduler adds it through the `cron_schedules` filter with an `interval` of 60 seconds. The event is registered through [`wp_schedule_event()`](https://developer.wordpress.org/reference/functions/wp_schedule_event/) with an argument array of `['WP Cron']`, which is why a raw look at the cron array shows the hook carrying one argument. And the callback is the queue runner's `run()` method, not a standalone function. That argument array matters more than it looks. Because `wp_next_scheduled()` matches on hook name _and_ arguments, an event scheduled with different args is a different event — which is how a manual `do_action()` call and the cron-driven pass can be told apart in logs. / Dispatch ## What happens when the hook **fires**? `ActionScheduler_QueueRunner::run()` executes with the context it was scheduled with, and attempts one queue pass: PHP — the callback signature ``` public function run( $context = 'WP Cron' ) { // 1. Bail if the concurrent-batch limit is already reached. // 2. Claim a batch of due actions (default 25). // 3. Execute each action until the time limit (default 30s). // 4. Release the claim. } ``` The `$context` string is purely descriptive — it identifies _what_ triggered this pass, and shows up in the logs written to `wp_actionscheduler_logs`. You will see `WP Cron` for the scheduled event, `Async Request` for a loopback pass, and `WP CLI` when a pass is driven from the command line. It does not change the work performed. Step 1 is the one that surprises people. Before doing anything, the runner checks whether another batch is already in flight, and exits immediately if the concurrency limit is hit. By default that limit is one, so on a site where a long-running action is still executing, the next minute's tick does nothing at all. That is deliberate — it protects the database from parallel claim contention — and it is tunable, as covered in the [concurrent batches filter reference](https://wpwebhooks.org/blog/action-scheduler-concurrent-batches-filter/). try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Limits ## Which **filters** change what a pass does? Four, and their defaults are documented in the [Action Scheduler performance guide](https://actionscheduler.org/perf/): | Filter | Default | What it controls | | --- | --- | --- | | `action_scheduler_queue_runner_batch_size` | 25 | How many actions a single claim locks in one batch. | | `action_scheduler_queue_runner_concurrent_batches` | 1 | How many batches may run at the same time across all runners. | | `action_scheduler_queue_runner_time_limit` | 30 | Seconds one pass may spend executing before it stops claiming more work. | | `action_scheduler_run_queue` | — | Not a filter — the cron hook itself. Fire it with `do_action()` to force a pass. | The time limit is why raising the batch size alone rarely helps: a pass that runs out of its 30 seconds stops mid-batch regardless of how many actions it claimed. Throughput on a backed-up queue comes from more passes or more concurrency, not bigger batches. The rows those batches lock live in the claims table — see the [Action Scheduler database schema](https://wpwebhooks.org/blog/action-scheduler-database-tables/) for how a `claim_id` is minted and released. > The queue runner is triggered at most once every minute. Everything beyond that first pass comes from asynchronous loopback requests, not from cron. — Action Scheduler performance guide / Loopback ## How does the **async loopback runner** fit in? It is how Action Scheduler drains a backlog faster than one batch per minute. A once-a-minute cron tick that processes 25 actions caps you at 1,500 actions an hour — nowhere near enough for a store generating thousands of jobs. So when a cron-triggered pass finishes and work is still due, the runner dispatches an asynchronous loopback request to the site, which runs another pass immediately. The practical consequence: loopback requests are HTTP requests your site makes to itself. If they are blocked — by HTTP authentication, a firewall rule, a misconfigured host, or a plugin that intercepts loopbacks — the queue silently falls back to the once-a-minute cron rate. A backlog that shrinks at exactly 25 actions per minute is the signature of blocked loopbacks, not of a slow database. / Troubleshooting ## Why is the hook **scheduled but nothing runs**? Because a scheduled WP-Cron event is only a promise. WP-Cron is triggered by traffic: on each page load WordPress checks whether anything is due and spawns a request to run it. No traffic, no tick — and no queue pass. Three failure modes cover almost every case: 1. **`DISABLE_WP_CRON` is true with no replacement.** Defining the constant switches off the page-load trigger. If nothing calls `wp-cron.php` on a schedule after that, `action_scheduler_run_queue` is scheduled forever and fired never. The fix is a real system cron entry, covered in the [WP-Cron developer reference](https://wpwebhooks.org/blog/wp-cron-developer-reference/); the warnings this produces are decoded in [DISABLE\_WP\_CRON is set to true](https://wpwebhooks.org/blog/disable-wp-cron-warnings/). 2. **Loopback requests are blocked.** The queue still moves, but only at one batch per minute. Check whether the site can make HTTP requests to itself. 3. **A long-running action holds the only concurrency slot.** Each minute's tick sees the limit reached and exits. The queue looks frozen while one action is in fact still executing. / Manual runs ## How do you **trigger a queue pass** by hand? Three ways, in increasing order of control. In PHP, fire the hook directly — this runs a pass synchronously in the current request: PHP — force one queue pass ``` do_action( 'action_scheduler_run_queue', 'Manual' ); ``` From the command line, Action Scheduler ships its own WP-CLI command, which is the right tool for clearing a backlog because it bypasses both the web request time limit and the concurrency guard: Shell — WP-CLI ``` # Run batches until the queue is empty (--batches=0 means "keep going"). wp action-scheduler run --batches=0 # Larger batches, only one hook, ignoring the concurrency limit. wp action-scheduler run --batch-size=200 --hooks=my_plugin_sync_order --force ``` Note that the [WP-CLI command](https://actionscheduler.org/wp-cli/) defaults to a batch size of **100**, not the 25 used by the web queue runner — it assumes it is running without a web request's constraints. And `--force` overrides the concurrency limit, which is exactly what you want for a one-off drain and exactly what you do not want on a cron schedule. For the functions that put actions into the queue in the first place, see the [Action Scheduler PHP API reference](https://wpwebhooks.org/blog/action-scheduler-api-functions/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"action_scheduler_run_queue: Queue Runner Hook Docs","description":"action_scheduler_run_queue is the WP-Cron hook behind every Action Scheduler queue pass: how it is registered, its every_minute schedule, and how to trigger it.","datePublished":"2026-07-26","dateModified":"2026-07-26","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/action-scheduler-run-queue-hook/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/og_image.jpg","width":1200,"height":630,"caption":"action_scheduler_run_queue: Queue Runner Hook Docs"},"keywords":["action scheduler run queue","action scheduler queue runner","action scheduler cron hook","every minute wordpress cron schedule","action scheduler batch size","action scheduler time limit","wp action scheduler run","action scheduler async loopback"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"action_scheduler_run_queue: Queue Runner Hook Docs","item":"https://wpwebhooks.org/blog/action-scheduler-run-queue-hook/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is action_scheduler_run_queue?","acceptedAnswer":{"@type":"Answer","text":"It is the WP-Cron hook Action Scheduler schedules for itself, and the entry point for all queue processing. When WP-Cron fires the hook, ActionScheduler_QueueRunner::run() executes one queue pass: it claims a batch of due actions, runs them, and releases the claim."}},{"@type":"Question","name":"How often does action_scheduler_run_queue run?","acceptedAnswer":{"@type":"Answer","text":"It is registered on Action Scheduler own every_minute schedule, a 60-second interval the library adds through the cron_schedules filter, so it fires at most once a minute. Anything faster than that comes from asynchronous loopback requests the runner dispatches when work is still due after a pass."}},{"@type":"Question","name":"How do I manually run the Action Scheduler queue?","acceptedAnswer":{"@type":"Answer","text":"In PHP call do_action('action_scheduler_run_queue', 'Manual') to run one pass synchronously. On the command line use wp action-scheduler run --batches=0 to keep processing until the queue is empty, adding --force to bypass the concurrency limit and --batch-size to change the default of 100."}},{"@type":"Question","name":"Why is my Action Scheduler queue stuck even though the hook is scheduled?","acceptedAnswer":{"@type":"Answer","text":"A scheduled WP-Cron event only runs when something triggers WP-Cron. The three usual causes are DISABLE_WP_CRON set with no replacement system cron, blocked loopback requests limiting the queue to one batch per minute, and a long-running action holding the single concurrency slot so every tick exits immediately."}},{"@type":"Question","name":"What is the context argument passed to the queue runner?","acceptedAnswer":{"@type":"Answer","text":"A descriptive string identifying what triggered the pass. The scheduled cron event passes WP Cron, loopback passes use Async Request, and command-line runs use WP CLI. It is recorded in the Action Scheduler logs and does not change the work performed."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/action-scheduler-run-queue.png","caption":"FIG 01 — How action_scheduler_run_queue starts a queue pass","description":"Action Scheduler registers a recurring WP-Cron event on the hook action_scheduler_run_queue using its own every_minute schedule. When WP-Cron fires that hook, the queue runner run method executes with the context string WP Cron. It first checks whether another batch is already running, and stops if the concurrent batch limit is reached. Otherwise it claims a batch of due actions, executes each hook until the time limit or batch size is reached, then releases the claim and may fire an asynchronous loopback request to continue draining the queue before the next minute tick.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Elementor Forms Webhook: Send Submissions Anywhere" description: "Elementor forms webhook setup, two ways: the built-in Webhook action in Elementor Pro, and the elementor_pro/forms/new_record hook when you need real control." url: "https://wpwebhooks.org/blog/elementor-forms-webhook/" date: "2026-07-24" --- # Elementor Forms Webhook: Send Submissions Anywhere **TL;DR:** Elementor can send form submissions to a webhook two ways, and both need Elementor Pro — the Form widget is a Pro feature. - **No code:** add _Webhook_ under _Actions After Submit_ in the Form widget and paste your URL. - **With code:** hook `elementor_pro/forms/new_record`, which fires after the form actions have run and hands you the record. - The code path is what you need when the endpoint expects a specific payload shape or an authentication header. - Either way, a submission sent inline blocks the visitor's request and is lost if the endpoint is down. / Overview ## Can Elementor send form submissions to a **webhook**? Yes — a Webhook action is built into the Form widget, no code required. It is one of the default [form actions](https://developers.elementor.com/docs/form-actions/) Elementor Pro ships, alongside Email, Redirect, Collect Submissions, Slack, Discord and a set of CRM integrations. The prerequisite catches people out: the Form widget itself is part of Elementor Pro and is not available in the free plugin. If you are on free Elementor there is no Webhook action to configure, because there is no Elementor form to attach it to. Everything below assumes Pro. FIG 01 — Two paths from an Elementor form to your endpoint / Built-in action ## Where is the built-in **Webhook action**? In the Form widget's _Content_ tab, under _Actions After Submit_. Add _Webhook_ to the action list and a Webhook panel appears below, where you paste the destination URL. On submission Elementor posts the form data to that URL. By default each field is sent using its label as the field name, which is convenient in a no-code tool and fragile everywhere else — renaming a label in the editor renames the key in your payload and quietly breaks the consumer. Give each field an explicit Custom ID in the field's _Advanced_ settings and use that as the stable key instead. That is the whole configuration surface, and its limits are the reason the code path exists. You get one URL and Elementor's payload shape. There is no place to add an `Authorization` header, no way to rename or nest fields to match an API contract, no signature, and no visibility into whether the request succeeded. / The hook ## How does **elementor\_pro/forms/new\_record** work? It is an action that fires _after_ the configured form actions have run, and it receives the submitted record plus the AJAX handler. [Elementor's developer documentation](https://developers.elementor.com/docs/hooks/forms/) gives the signature as two parameters: | Parameter | Type | What it holds | | --- | --- | --- | | `$record` | `Form_Record` | The submitted form: fields, settings, uploaded files and meta. | | `$ajax_handler` | `Ajax_Handler` | The response handler — used to add success or error messages back to the visitor. | Because it runs after the actions, anything the built-in Webhook action was going to send has already gone out by the time your callback runs. Use `new_record` when you want to send your _own_ request; use it alongside the built-in action only if you genuinely want two deliveries. Two related hooks are worth knowing. `elementor_pro/forms/validation` runs before processing and is where you reject a submission. `elementor_pro/forms/process` fires after fields are validated and processed. Both take the same `$record` and `$ajax_handler` pair, and both have per-field-type variants — `elementor_pro/forms/validation/{$field_type}` and `elementor_pro/forms/process/{$field_type}` — for targeting a single kind of field. > elementor\_pro/forms/new\_record fires after the form actions have run — so it is a delivery hook, not a validation hook. — Elementor developer documentation / Implementation ## How do you **post a submission** to your own endpoint? Read the form name to make sure you are handling the right form, pull the fields off the record, and send them. The form-name guard is not optional — without it your callback fires for every Elementor form on the site. PHP — send one Elementor form to a custom endpoint ``` add_action( 'elementor_pro/forms/new_record', function( $record, $ajax_handler ) { // Only handle the form named "Contact" in the editor. if ( 'Contact' !== $record->get_form_settings( 'form_name' ) ) { return; } // Raw fields, keyed by each field's Custom ID. $raw = $record->get( 'fields' ); $payload = []; foreach ( $raw as $id => $field ) { $payload[ $id ] = $field['value']; } wp_remote_post( 'https://example.com/hooks/elementor', [ 'timeout' => 5, 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . get_option( 'my_endpoint_token' ), ], 'body' => wp_json_encode( [ 'source' => 'elementor', 'form' => $record->get_form_settings( 'form_name' ), 'submitted' => gmdate( 'c' ), 'fields' => $payload, ] ), ] ); }, 10, 2 ); ``` Each entry in `$record->get('fields')` is an array, not a bare string — `value` holds what the visitor typed, and `title` holds the field label. Reading `['value']` explicitly is what keeps the payload clean. Note the header block: this is the capability the built-in action does not have, and usually the reason people move to code in the first place. Uploaded files arrive as URLs rather than binary content, so a file field's `value` is a link into the uploads directory. If your endpoint needs the file itself, fetch it from that URL — and remember the URL is public unless you have restricted upload access. ![Cyberpunk illustration of an Elementor form submission travelling to a webhook: a neon clipboard icon labelled “Form” and a glowing holographic form panel with input fields and a green submit button on the left, wired by cyan circuit traces and streams of binary through a large circular port labelled “Webhook” bearing the webhook glyph, with thick cables running off into a purple night skyline.](https://wpwebhooks.org/blog/elementor-forms-webhook/og_image.jpg) / Reliability ## Why does a **synchronous request** hurt your form? Because it runs inside the visitor's submission request. [`wp_remote_post()`](https://developer.wordpress.org/reference/functions/wp_remote_post/) is blocking: with the five-second timeout above, a slow endpoint adds five seconds to every submission before the visitor sees a confirmation. Drop the timeout and you cut off legitimate slow responses; raise it and a degraded endpoint stalls your form. Worse is what happens on failure. The visitor's submission succeeded, Elementor showed a success message, and your delivery threw a connection error into the void. Nothing retries it. Nobody is told. This is the standard failure mode for form webhooks and it is [almost always silent](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/). | Concern | wp\_remote\_post inside the hook | Queued delivery | | --- | --- | --- | | Submission speed | Visitor waits for your endpoint | Returns immediately — job runs after | | Endpoint down | Delivery lost, no record | Job stays queued and retries | | Slow endpoint | Adds latency to every submission | No effect on the form | | Visibility | Failure is invisible unless you log it | Per-attempt status is inspectable | | Traffic spikes | Each submission opens its own request | Work is batched by the queue runner | The fix is to enqueue rather than send. Capture the payload in the hook, hand it to a background job, and let a queue runner deliver it with retries and [exponential backoff](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/). [Action Scheduler](https://actionscheduler.org/) is the usual choice on WordPress because it gives each delivery its own database row and retry state — the same pattern described in the [WordPress job queue guide](https://wpwebhooks.org/blog/wordpress-job-queue/). try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Choosing ## Which approach should you **use**? Start with the built-in action, and move to the hook when you hit one of its walls. Concretely: 1. **Use the built-in Webhook action** when the destination accepts whatever you send it — an automation platform's catch-hook node, an internal script, a test endpoint. Set Custom IDs on your fields first. 2. **Use `elementor_pro/forms/new_record`** when the endpoint needs an authentication header, a specific JSON shape, nested objects, or fields renamed to match an API contract. 3. **Queue the delivery** as soon as the submission matters commercially. A lost contact form entry is an annoyance; a lost order or lead is revenue. The same trade-off shows up in every form plugin — the built-in option is fastest to set up, the hook gives control, and neither gives you retries on its own. For the equivalent hooks in other form plugins, see the [gform\_after\_submission reference](https://wpwebhooks.org/blog/gravity-forms-gform-after-submission-webhook/), the [wpcf7\_mail\_sent guide](https://wpwebhooks.org/blog/wordpress-wpcf7-mail-sent-hook/) and the [WPForms webhook guide](https://wpwebhooks.org/blog/wpforms-webhook/). If the destination is a Slack channel rather than your own endpoint, [posting form entries to Slack](https://wpwebhooks.org/blog/wordpress-form-to-slack/) covers all three completion hooks behind one payload builder. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Elementor Forms Webhook: Send Submissions Anywhere","description":"Elementor forms webhook setup, two ways: the built-in Webhook action in Elementor Pro, and the elementor_pro/forms/new_record hook when you need real control.","datePublished":"2026-07-24","dateModified":"2026-07-24","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/elementor-forms-webhook/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/elementor-forms-webhook/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of an Elementor form submission travelling to a webhook: a neon clipboard icon labelled “Form” and a glowing holographic form panel with input fields and a green submit button on the left, wired by cyan circuit traces and streams of binary through a large circular port labelled “Webhook” bearing the webhook glyph, with thick cables running off into a purple night skyline."},"keywords":["elementor forms webhook","elementor webhook","elementor pro forms","elementor form submission hook","elementor pro forms new record","elementor actions after submit","wordpress form webhook","elementor form api"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Elementor Forms Webhook: Send Submissions Anywhere","item":"https://wpwebhooks.org/blog/elementor-forms-webhook/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does Elementor have a webhook for forms?","acceptedAnswer":{"@type":"Answer","text":"Yes. The Form widget ships a Webhook action under Actions After Submit — add it in the Content tab and paste the destination URL. It requires Elementor Pro, because the Form widget itself is a Pro feature and is not available in free Elementor."}},{"@type":"Question","name":"What data does the Elementor webhook action send?","acceptedAnswer":{"@type":"Answer","text":"The submitted form fields, keyed by each field name. By default the field label is used as the key, which breaks your consumer whenever a label is edited, so set an explicit Custom ID on each field in its Advanced settings and rely on that instead."}},{"@type":"Question","name":"What is elementor_pro/forms/new_record?","acceptedAnswer":{"@type":"Answer","text":"It is the Elementor Pro action hook that fires after the form actions have run. It receives two parameters: a Form_Record object holding the fields, settings and uploaded files, and an Ajax_Handler used to add success or error messages back to the visitor."}},{"@type":"Question","name":"How do I send Elementor form data to a custom API with headers?","acceptedAnswer":{"@type":"Answer","text":"Use elementor_pro/forms/new_record rather than the built-in action. Guard on the form name via $record->get_form_settings('form_name'), read the fields with $record->get('fields'), build your own JSON body, and pass an Authorization header to wp_remote_post. The built-in action has no place to set headers."}},{"@type":"Question","name":"Why should Elementor webhook deliveries be queued?","acceptedAnswer":{"@type":"Answer","text":"Because wp_remote_post inside the hook is blocking and unretried: a slow endpoint adds its latency to every submission, and a failed request is lost silently while the visitor still sees a success message. Enqueueing the delivery lets a background worker retry with exponential backoff without affecting the form."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/elementor-forms-webhook.png","caption":"FIG 01 — Two paths from an Elementor form to your endpoint","description":"A visitor submits an Elementor Pro form over admin-ajax. Elementor validates and processes the fields, then runs each configured after-submit action in turn. The built-in Webhook action posts the submission to the URL configured in the editor, with no code required. After all actions have run, Elementor fires the elementor_pro/forms/new_record hook, where PHP code can read the record and send its own request. Both paths reach an external endpoint, but only the code path lets you reshape the payload, add authentication headers, or queue the delivery for retry.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "wp_actionscheduler_actions: Table Schema & Columns" description: "wp_actionscheduler_actions database schema — every column in the actions, claims, groups and logs tables, their indexes, and how the queue runner uses them." url: "https://wpwebhooks.org/blog/action-scheduler-database-tables/" date: "2026-07-23" --- # wp_actionscheduler_actions: Table Schema & Columns **TL;DR:** Action Scheduler stores every background job across **four** MySQL tables, all prefixed `wp_actionscheduler_`. - `wp_actionscheduler_actions` — one row per scheduled job (hook, args, status, scheduled date). - `wp_actionscheduler_claims` — one row per batch a runner locks, handing out a `claim_id`. - `wp_actionscheduler_groups` — a slug lookup so actions can be grouped and filtered. - `wp_actionscheduler_logs` — one row per lifecycle event and execution attempt. / Overview ## What tables does **Action Scheduler** create? [Action Scheduler](https://actionscheduler.org/) — the background-job library maintained by WooCommerce and bundled inside WooCommerce, Jetpack, and hundreds of other plugins — creates four custom tables the first time it loads. On a standard single install they are `wp_actionscheduler_actions`, `wp_actionscheduler_claims`, `wp_actionscheduler_groups`, and `wp_actionscheduler_logs` (your prefix replaces `wp_`). Together they replace the single serialised `cron` option that [WP-Cron](https://developer.wordpress.org/plugins/cron/) uses, giving each job its own indexed row, a lock, a group, and an audit trail. The default data store is the custom-table store shipped with the library. Older or constrained installs can fall back to a post-type store that keeps actions in `wp_posts`, but the custom tables are the standard and the ones you will inspect in production. The [Scheduled Actions admin screen](https://actionscheduler.org/admin/) reads directly from them. FIG 01 — How the four Action Scheduler tables work together / actions ## What columns are in **wp\_actionscheduler\_actions**? This is the core table — one row is one scheduled job. Every function in the [Action Scheduler PHP API](https://wpwebhooks.org/blog/action-scheduler-api-functions/) ultimately writes or reads a row here. The columns that matter most when you are debugging a stuck queue are `status`, `scheduled_date_gmt`, and `claim_id`. | Column | Type | What it holds | | --- | --- | --- | | `action_id` | bigint | Primary key. The integer returned by the scheduling functions. | | `hook` | varchar | The action hook fired via `do_action()` when the job runs. | | `status` | varchar | `pending`, `in-progress`, `complete`, `failed`, or `canceled`. | | `scheduled_date_gmt` | datetime | Earliest time the action may run. The runner claims rows whose value is in the past. | | `args` | varchar | JSON-encoded arguments spread into the callback. A hash is also stored for dedup matching. | | `group_id` | bigint | Foreign key into `wp_actionscheduler_groups`. | | `claim_id` | bigint | `0` when unclaimed; a claim row id while a runner holds the action. | | `attempts` | int | How many times the runner has tried to execute the action. | | `last_attempt_gmt` | datetime | Timestamp of the most recent execution attempt. | The `args` column is indexed via a stored hash rather than the raw JSON, which is how the `$unique` flag can match "the same hook, args and group" quickly. Keep argument payloads small — large serialised arrays bloat both this column and every dedup lookup. / claims ## What is the **wp\_actionscheduler\_claims** table for? The claims table is how Action Scheduler stops two queue runners from executing the same job. It is deliberately tiny — essentially just an id and a timestamp. When a [queue pass starts](https://wpwebhooks.org/blog/action-scheduler-run-queue-hook/) and a runner wants work, it inserts one claim row to mint a fresh `claim_id`, then runs an `UPDATE` that stamps that id onto a batch of due, unclaimed actions. SQL — how a runner claims a batch (simplified) ``` -- 1. mint a claim id INSERT INTO wp_actionscheduler_claims (date_created_gmt) VALUES (UTC_TIMESTAMP()); -- claim_id = LAST_INSERT_ID() -- 2. lock a batch of due, unclaimed actions UPDATE wp_actionscheduler_actions SET claim_id = :claim_id, last_attempt_gmt = UTC_TIMESTAMP() WHERE claim_id = 0 AND status = 'pending' AND scheduled_date_gmt <= UTC_TIMESTAMP() ORDER BY scheduled_date_gmt LIMIT 25; ``` Because that `UPDATE` filters on `claim_id = 0`, only one runner can win each row. When the batch finishes, the claim row is deleted and the actions' `claim_id` resets. This is also the exact statement that can deadlock when several runners scan overlapping rows at once — the subject of [Action Scheduler MySQL deadlocks under concurrent workers](https://wpwebhooks.org/blog/woocommerce-action-scheduler-mysql-deadlocks/). > The claims table is a lock table. A non-zero `claim_id` on an action means "a runner owns this row" — that single column is what makes concurrent processing safe. — Action Scheduler data store try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / groups + logs ## What do the **groups** and **logs** tables store? `wp_actionscheduler_groups` is a simple lookup: an `group_id` and a `slug`. The free-text group you pass to a scheduling function (for example `'order-webhooks'`) is normalised into one row here, and actions reference it by `group_id`. That is what powers the group filter in the admin UI and bulk operations like `as_unschedule_all_actions()`. `wp_actionscheduler_logs` records the lifecycle. Each row ties an `action_id` to a message and a timestamp — "action created", "action started via WP Cron", "action complete", or the exception message when a run throws. This is the history that raw WP-Cron events can never give you, and it is what the admin screen renders when you expand an action's log. | Table | Key columns | Role | | --- | --- | --- | | `wp_actionscheduler_groups` | group\_id, slug | Normalises group labels; actions reference it by id. | | `wp_actionscheduler_logs` | log\_id, action\_id, message, log\_date\_gmt | Per-event and per-attempt audit trail shown in wp-admin. | / Inspecting ## How do you inspect the tables safely with SQL? Reading is safe and often the fastest way to diagnose a backlog. A count of actions grouped by status tells you at a glance whether the queue is draining or stuck. Use the [`$wpdb`](https://developer.wordpress.org/reference/classes/wpdb/) object rather than a hard-coded prefix so the query works on any install. PHP — how healthy is the queue right now? ``` global $wpdb; $rows = $wpdb->get_results( "SELECT status, COUNT(*) AS n FROM {$wpdb->prefix}actionscheduler_actions GROUP BY status" ); // pending piling up while in-progress stays high = runner is stalled ``` **Do not** delete rows by hand as a cleanup strategy. Actions are linked to log rows, and an in-progress action still referenced by a live claim will confuse the runner. Cancel through `as_unschedule_action()` / `as_unschedule_all_actions()` or the admin screen, and let Action Scheduler's own retention task trim completed and failed rows. / Maintenance ## Why do these tables grow, and how is that managed? Completed and failed actions are retained so you can audit them — by default for 30 days — together with every log row. On a busy WooCommerce store that is easily millions of rows. Action Scheduler ships a built-in cleanup action that deletes old, finished actions past the retention window; the [performance guide](https://actionscheduler.org/perf/) documents tuning batch sizes and the retention period for high-throughput sites. If your `actions` and `logs` tables are enormous, the fix is tuning retention and indexes — not truncating the tables. For the functions that create and query all of these rows — scheduling, cancelling, and checking whether an action exists — see the [Action Scheduler PHP API reference](https://wpwebhooks.org/blog/action-scheduler-api-functions/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"wp_actionscheduler_actions: Table Schema & Columns","description":"wp_actionscheduler_actions database schema — every column in the actions, claims, groups and logs tables, their indexes, and how the queue runner uses them.","datePublished":"2026-07-23","dateModified":"2026-07-23","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/action-scheduler-database-tables/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["wp actionscheduler actions","wp actionscheduler claims","action scheduler database tables","action scheduler schema","action scheduler mysql tables","wp actionscheduler groups","wp actionscheduler logs"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"wp_actionscheduler_actions: Table Schema & Columns","item":"https://wpwebhooks.org/blog/action-scheduler-database-tables/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How many database tables does Action Scheduler create?","acceptedAnswer":{"@type":"Answer","text":"Four, all prefixed wp_actionscheduler_: actions (one row per scheduled job), claims (one row per batch a runner locks), groups (a lookup of group slugs), and logs (one row per lifecycle event or attempt)."}},{"@type":"Question","name":"What is the wp_actionscheduler_claims table for?","acceptedAnswer":{"@type":"Answer","text":"It hands out claim IDs. A queue runner inserts one row to get a unique claim_id, then stamps that id onto a batch of due pending actions so no other runner can process the same rows. When the batch finishes the claim row is deleted."}},{"@type":"Question","name":"What does the claim_id column in wp_actionscheduler_actions mean?","acceptedAnswer":{"@type":"Answer","text":"A non-zero claim_id means a runner has locked that action for execution. Zero means the action is unclaimed and available. The runner sets it in an UPDATE that also filters on claim_id = 0, which is where concurrent runners can deadlock under load."}},{"@type":"Question","name":"Can I query or delete Action Scheduler rows directly in MySQL?","acceptedAnswer":{"@type":"Answer","text":"You can read them freely for debugging. Deleting rows by hand is risky: prefer the as_unschedule_* functions or the admin Scheduled Actions screen, and only bulk-purge completed/failed rows once you understand the foreign relationships to the logs table."}},{"@type":"Question","name":"Why do the Action Scheduler tables grow so large?","acceptedAnswer":{"@type":"Answer","text":"Completed and failed actions are retained (default 30 days) along with their log rows for auditing. High-volume sites accumulate millions of rows; the built-in cleanup task trims old actions, and actionscheduler.org documents tuning the retention window."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/action-scheduler-database-tables.png","caption":"FIG 01 — How the four Action Scheduler tables work together","description":"Action Scheduler stores every job as one row in wp_actionscheduler_actions, tagged by a slug in wp_actionscheduler_groups. To run work, the queue runner inserts a row in wp_actionscheduler_claims to get a claim_id, stamps that claim_id onto a batch of due pending actions so no other runner can take them, executes each hook, and writes a per-attempt message to wp_actionscheduler_logs before releasing the claim.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WP Engine Alternate Cron: How WordPress Cron Really Runs" description: "WP Engine Alternate Cron explained: what it changes, how it curls wp-cron.php every minute, and when you still need a real system cron job." url: "https://wpwebhooks.org/blog/wp-engine-cron-jobs/" date: "2026-07-20" --- # WP Engine Alternate Cron: How WordPress Cron Really Runs **TL;DR:** WP Engine does _not_ disable WordPress cron by default — it is still traffic-dependent until you turn on **WP Engine Alternate Cron**. - By default, WP-Cron fires on visitor page loads, just like any host. - Enabling Alternate Cron in the User Portal sets `DISABLE_WP_CRON = true` and runs a server service that curls `wp-cron.php` every minute. - Defining `DISABLE_WP_CRON` yourself only stops the trigger — it does _not_ enable the replacement. / Default ## Does **WP Engine** run WordPress cron jobs? Yes — and by default it runs them the ordinary way. Contrary to a common assumption, WP Engine does not automatically disable WordPress cron. Out of the box, [WP-Cron](https://developer.wordpress.org/plugins/cron/) on a WP Engine site is _traffic-dependent_: WordPress checks for due scheduled events on visitor page loads and runs whatever is due. Per [WP Engine's own documentation](https://wpengine.com/support/wp-cron-wordpress-scheduling/), "simply disabling wp cron within the environment's config file will not enable WP Engine alternate cron on its own." That default is fine for a busy site with steady traffic but unreliable for a quiet one: with no visitors, nothing triggers the cron, and scheduled tasks pile up until the next page load. That is the exact problem WP Engine's Alternate Cron exists to solve. ![Cyberpunk illustration titled “How WordPress Cron Really Runs on WP Engine — Uncovering the Mechanics Behind the Scenes”: a glowing WordPress logo hovers above a neon machine labelled “WP Engine Platform”, wired to labelled circuit lines reading wp-cron.php, Scheduler, Tasks, HTTP Requests and Job Queue.](https://wpwebhooks.org/blog/wp-engine-cron-jobs/og_image.jpg) / Alternate Cron ## How does **WP Engine Alternate Cron** work? WP Engine Alternate Cron is an opt-in, server-based service you enable from the User Portal. When you turn it on, WP Engine adds `define( 'DISABLE_WP_CRON', true )` to `wp-config.php` for you — switching off the traffic-based trigger — and starts a platform process that, in WP Engine's words, "checks for 'due now' crons every minute by curling wp-cron.php, rather than running cron processes based on traffic flow." FIG 01 — Default page-load cron vs WP Engine Alternate Cron The result is a predictable one-minute heartbeat that is independent of whether anyone is visiting the site. Every minute the service hits `wp-cron.php`, WordPress evaluates the schedule, and any due hooks fire via `do_action()`. If nothing is due, the request exits and waits for the next tick. > Alternate Cron checks for "due now" crons every minute by curling wp-cron.php, rather than running cron processes based on traffic flow. — WP Engine support documentation / Enabling ## How do you enable it, and why did **DISABLE\_WP\_CRON** alone not work? Enable Alternate Cron through the WP Engine User Portal for the environment — not by editing files. This is the point that trips people up: adding `define( 'DISABLE_WP_CRON', true )` to `wp-config.php` by hand disables the page-load trigger but starts _nothing_ in its place, so your events simply stop running. The warnings that follow are decoded in [DISABLE\_WP\_CRON is set to true](https://wpwebhooks.org/blog/disable-wp-cron-warnings/). PHP — this line alone leaves nothing running your cron ``` // Disables the traffic-based trigger… define( 'DISABLE_WP_CRON', true ); // …but WP Engine Alternate Cron is what actually curls wp-cron.php. // Enable it in the User Portal — do not just define the constant. ``` Once Alternate Cron is on, you can confirm the constant is set by checking Site Health, which will report that `DISABLE_WP_CRON` is `true` and that WP-Cron spawning is disabled — expected and correct on WP Engine with Alternate Cron enabled. / Limits ## What are the limits of one-minute cron? A one-minute tick is reliable but not instant. Every scheduled event still only fires on the next minute boundary, so a task scheduled for `time() + 5` can be up to roughly a minute late. For the vast majority of WordPress work — sending queued email, expiring transients, syncing feeds — that is invisible. It matters when you need sub-minute latency or exactly-on-the-second execution, which cron of any kind is the wrong tool for. | Concern | Default (page-load) cron | WP Engine Alternate Cron | | --- | --- | --- | | Trigger | Visitor page loads | Server service, every minute | | Runs with zero traffic | No — tasks stall | Yes — independent of visitors | | `DISABLE_WP_CRON` | Not set | Set to `true` automatically | | Latency | Whenever the next visitor arrives | ≤ ~1 minute | | How to enable | Default | User Portal toggle | / Real cron ## When should you use a real system cron instead? If you want tighter control than a one-minute curl, the general WordPress recommendation applies: disable the built-in trigger and drive `wp-cron.php` from a real scheduler at whatever interval you choose. On most hosts that is a server crontab entry; on WP Engine, Alternate Cron already is that server-side scheduler, so the practical move is to enable it rather than roll your own. The mechanics of hooking events, and the trade-offs of external triggers, are covered in the [WP-Cron developer reference](https://wpwebhooks.org/blog/wp-cron-developer-reference/) and in [setting up a real cron job for WordPress](https://wpwebhooks.org/blog/cron-job-for-wordpress/). | Need | Page-load WP-Cron | Server-driven cron (incl. Alternate Cron) | | --- | --- | --- | | Fires without traffic | No | Yes, on a fixed interval | | Predictable timing | No — depends on visitors | Yes — every minute (WP Engine) | | Setup | None, but unreliable | One toggle in the User Portal | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Queues ## Should you use **Action Scheduler** on WP Engine? For heavy, retryable background work — bulk imports, thousands of outbound API calls, order processing — a cron heartbeat alone is not enough; you want a queue on top of it. [Action Scheduler](https://actionscheduler.org/) gives each job its own database row, automatic retries, and a delivery log, but it still needs a cron tick to run its queue. On WP Engine that means Alternate Cron provides the reliable minute-by-minute heartbeat, and Action Scheduler drains the queue on each pass. For the difference between a bare scheduled event and a real queue, see [WordPress async background processing](https://wpwebhooks.org/blog/wordpress-async-background-processing/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WP Engine Alternate Cron: How WordPress Cron Really Runs","description":"WP Engine Alternate Cron explained: what it changes, how it curls wp-cron.php every minute, and when you still need a real system cron job.","datePublished":"2026-07-20","dateModified":"2026-07-20","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wp-engine-cron-jobs/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wp-engine-cron-jobs/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration titled “How WordPress Cron Really Runs on WP Engine — Uncovering the Mechanics Behind the Scenes”: a glowing WordPress logo hovers above a neon machine labelled “WP Engine Platform”, wired to labelled circuit lines reading wp-cron.php, Scheduler, Tasks, HTTP Requests and Job Queue."},"keywords":["wpengine cron","wp engine cron","wpengine cron jobs","wpengine alternate cron","wp engine wp cron","wpengine cron job"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WP Engine Alternate Cron: How WordPress Cron Really Runs","item":"https://wpwebhooks.org/blog/wp-engine-cron-jobs/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does WP Engine disable WP-Cron by default?","acceptedAnswer":{"@type":"Answer","text":"No. By default WordPress cron on WP Engine is traffic-dependent — it spawns on visitor page loads like anywhere else. Only when you enable WP Engine Alternate Cron does WP Engine set DISABLE_WP_CRON to true."}},{"@type":"Question","name":"How often does WP Engine Alternate Cron run?","acceptedAnswer":{"@type":"Answer","text":"Once a minute. The Alternate Cron service is a server-based process that curls wp-cron.php every minute to check for due-now events, rather than depending on site traffic to trigger the run."}},{"@type":"Question","name":"Why did just defining DISABLE_WP_CRON not fix my cron on WP Engine?","acceptedAnswer":{"@type":"Answer","text":"Because disabling WP-Cron only stops the traffic-based trigger — it does not enable any replacement. On WP Engine you must turn on Alternate Cron in the User Portal; setting the constant by hand leaves nothing running your events."}},{"@type":"Question","name":"Is one-minute cron accurate enough for scheduled tasks?","acceptedAnswer":{"@type":"Answer","text":"For most WordPress tasks, yes. But every event still only fires on the next minute tick, so a job scheduled for time()+5 can be up to a minute late. Sub-minute or exactly-on-time work needs a different mechanism."}},{"@type":"Question","name":"Should I use Action Scheduler instead of WP-Cron on WP Engine?","acceptedAnswer":{"@type":"Answer","text":"For queued, retryable background work — yes. Action Scheduler still relies on a cron tick to run its queue, so Alternate Cron gives it a reliable heartbeat, but it adds per-job rows, retries, and a delivery log that raw WP-Cron events lack."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wp-engine-cron.png","caption":"FIG 01 — Default page-load cron vs WP Engine Alternate Cron","description":"By default WP Engine leaves WordPress cron traffic-dependent: it spawns on visitor page loads. Enabling WP Engine Alternate Cron in the User Portal sets DISABLE_WP_CRON to true and switches to a server-based service that curls wp-cron.php once every minute regardless of traffic. On each tick WordPress checks for due events and runs any that are due, otherwise it exits and waits for the next minute. Simply defining DISABLE_WP_CRON by hand does not enable the service.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Job Queue: Build Your Own or Use a Library" description: "How a WordPress job queue really works: the queue table, safe claiming, retry limits, and when Action Scheduler beats writing your own worker." url: "https://wpwebhooks.org/blog/wordpress-job-queue/" date: "2026-07-19" --- # WordPress Job Queue: Build Your Own or Use a Library **TL;DR:** A WordPress job queue is a persistent table of pending work that a background worker drains, so slow tasks never block the web request. - A producer _enqueues_ a job by inserting a pending row instead of doing the work inline. - A worker _claims_ a batch (a conditional UPDATE), runs each job, and marks it complete. - Failures are retried while attempts remain, then marked failed — a per-item audit trail WP-Cron cannot give you. / Definition ## What is a **job queue** in WordPress? A job queue is a persistent list of work to be done later, drained by a background worker. Instead of running an expensive task inside the request that triggered it, you write a small row describing the task — the job — and return immediately. Something else picks that row up moments later and does the actual work. In WordPress that "something else" is almost always driven by a [WP-Cron](https://developer.wordpress.org/plugins/cron/) tick, and the list itself is a database table. The value is in decoupling. The user who placed the order, or the admin who clicked "sync 5,000 products", gets an instant response; the heavy lifting happens out of band, at a pace the server can sustain. This is the concrete, table-level view of the broader idea covered in [WordPress async background processing](https://wpwebhooks.org/blog/wordpress-async-background-processing/) — here we focus on the queue itself. FIG 01 — Producer, queue table and worker in a job queue / Why not WP-Cron ## Why not just use **WP-Cron** for background jobs? WP-Cron is a scheduler, not a queue. It stores every event in a single serialised `cron` array inside `wp_options`, runs on page loads, keeps no history, and never retries a failure. That is perfectly good for "run this once tonight." It falls apart when you have thousands of discrete jobs that each need to be claimed exactly once, retried on error, and audited afterward — there is nowhere in a serialised option to track per-job status or attempt counts. | Concern | Raw WP-Cron event | Job queue table | | --- | --- | --- | | Storage | One serialised wp\_options row | One indexed row per job | | Claiming | None — no concept of ownership | Conditional UPDATE locks a batch | | Retry | None | Attempt counter + backoff | | History | Gone once it runs | Per-job status and log | | Throughput control | All-or-nothing per tick | Batch size caps work per pass | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Schema ## What does a job queue look like in the database? At its simplest, a queue is one table. Each row carries the payload, a status, an attempt count, and timestamps. The `status` and `available_at` columns are what the worker filters on; the `attempts` column is what turns a transient failure into a retry rather than a lost job. | Column | Type | Purpose | | --- | --- | --- | | `id` | bigint | Primary key for the job. | | `payload` | longtext | JSON describing the work (an order id, a batch offset, an endpoint). | | `status` | varchar | `pending`, `processing`, `complete`, or `failed`. | | `attempts` | int | Incremented each run; drives retry and give-up logic. | | `available_at` | datetime | Earliest time the job may run — how you delay a retry. | | `reserved_at` | datetime | When a worker claimed it; lets you reclaim stuck jobs. | / Claiming ## How does a worker claim and process jobs safely? The hard part of any queue is making sure two concurrent workers never run the same job. The race-safe technique is a single conditional `UPDATE`: flip a batch of `pending` rows to `processing` and stamp them with a worker/claim marker, filtering on the current status so only one worker can win each row. Whatever rows that UPDATE actually changed are yours to process. PHP — claim a batch, then process it ``` global $wpdb; $claim = wp_generate_uuid4(); // Atomically reserve up to 10 due jobs for THIS worker $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}my_jobs SET status = 'processing', claim = %s, reserved_at = %s WHERE status = 'pending' AND available_at <= %s ORDER BY id LIMIT 10", $claim, current_time( 'mysql' ), current_time( 'mysql' ) ) ); // Only rows carrying our claim are ours to run $jobs = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}my_jobs WHERE claim = %s", $claim ) ); ``` This is exactly the pattern Action Scheduler formalises with its separate claims table — the claim marker is what guarantees single execution under concurrency. > A job queue is correct when a job is claimed by exactly one worker and either completes or is retried — never silently dropped, never run twice. — the core queue invariant / Retries ## How should a queue handle retries and failures? Wrap each job so a thrown exception does not kill the whole batch. On failure, increment `attempts`, and if it is still under the limit push the job back to `pending` with a future `available_at` — this is where [exponential backoff](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/) belongs, spacing retries out as failures accumulate. Once `attempts` hits the ceiling, mark the row `failed` so it stops consuming worker time and shows up for a human to inspect. That "give up cleanly and keep the evidence" step is what separates a real queue from an infinite retry loop. 1. **Run inside a try/catch.** One bad job must not abort the other nine in the batch. 2. **On error, increment attempts.** If under the limit, reset status to `pending` and push `available_at` into the future (backoff). 3. **At the limit, mark failed.** Stop retrying, keep the row and its last error for inspection. / Libraries ## What libraries give you a job queue in WordPress? You rarely need to hand-roll all of this. Three common options cover most needs, and each is a variation on the same enqueue-claim-process loop. | Option | What it gives you | Best for | | --- | --- | --- | | [Action Scheduler](https://actionscheduler.org/) | Custom tables, retries, groups, admin UI; bundled with WooCommerce | Most production sites; anything WooCommerce-adjacent | | [WP Background Processing](https://github.com/deliciousbrains/wp-background-processing) | Lightweight batch processing over WP-Cron and the options table | Simple one-off bulk tasks, few dependencies | | Custom `$wpdb` table | Total control over schema, claiming and retry | Unusual requirements the libraries do not model | For the overwhelming majority of cases the answer is Action Scheduler — it already implements claiming, retries, and an audit trail correctly. Its internals are a useful reference for how a mature queue is structured; see the [Action Scheduler database schema](https://wpwebhooks.org/blog/action-scheduler-database-tables/) and the [PHP API reference](https://wpwebhooks.org/blog/action-scheduler-api-functions/). / When ## When do you need a queue vs a single scheduled event? Reach for a queue when work is high-volume, must retry on failure, needs a per-item trail, or must not block the request. A single delayed task — one reminder email, one cleanup pass — is fine on a lone scheduled event and does not justify a table and a worker. The moment you have many independent jobs arriving in bursts, each of which can fail on its own, you want the queue. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Job Queue: Build Your Own or Use a Library","description":"How a WordPress job queue really works: the queue table, safe claiming, retry limits, and when Action Scheduler beats writing your own worker.","datePublished":"2026-07-19","dateModified":"2026-07-19","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-job-queue/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["wordpress job queue","wordpress queue","wp queue","wordpress background jobs","wordpress queue table","wordpress job queue library"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Job Queue: Build Your Own or Use a Library","item":"https://wpwebhooks.org/blog/wordpress-job-queue/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is a job queue in WordPress?","acceptedAnswer":{"@type":"Answer","text":"A job queue is a persistent list of pending work — usually a database table — that a background worker drains over time. The web request enqueues a job and returns immediately; the slow work runs later, outside the request that triggered it."}},{"@type":"Question","name":"Why not just use WP-Cron for background jobs?","acceptedAnswer":{"@type":"Answer","text":"WP-Cron stores every event in one serialised wp_options row, runs on page loads, keeps no history, and never retries. That is fine for a nightly task but not for thousands of discrete jobs that each need claiming, retrying, and logging."}},{"@type":"Question","name":"How does a worker avoid running the same job twice?","acceptedAnswer":{"@type":"Answer","text":"By claiming. The worker flips a batch of pending rows to a processing/claimed state in a single conditional UPDATE, so any other worker that runs concurrently sees those rows as already taken and skips them."}},{"@type":"Question","name":"What libraries give you a job queue in WordPress?","acceptedAnswer":{"@type":"Answer","text":"Action Scheduler (database-backed, retries, admin UI, bundled with WooCommerce) is the most common; WP Background Processing by Delicious Brains is a lighter option; and some teams build a custom table with their own worker. Each is a variation on the same enqueue-claim-process loop."}},{"@type":"Question","name":"When do I need a queue instead of a single scheduled event?","acceptedAnswer":{"@type":"Answer","text":"When work is high-volume, must retry on failure, needs a per-item audit trail, or must not block the request. A one-off delayed task is fine on a single scheduled event; recurring bursts of many independent jobs want a queue."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-job-queue.png","caption":"FIG 01 — Producer, queue table and worker in a job queue","description":"A producer enqueues a job by inserting a pending row into a queue table instead of doing the slow work inside the web request. A worker, triggered on a schedule, claims a batch of pending rows by flipping their status to processing so no other worker takes them, then runs each job. A successful job is marked complete; a failed job is retried while attempts remain and marked failed once the attempt limit is exhausted.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Gemini JSON Mode Can Drop the Final Closing Brace" description: "A real bug post-mortem: Gemini’s JSON mode returned our agent’s envelope without its closing brace — twice, deterministically — and the conservative repair that recovers it without ever inventing content." url: "https://wpwebhooks.org/blog/gemini-json-mode-truncated-response/" date: "2026-07-17" --- # Gemini JSON Mode Can Drop the Final Closing Brace **TL;DR:** While capturing screenshots for a documentation page, our in-admin AI agent lost the same turn twice: Gemini's JSON mode returned the reply envelope without its final closing brace. The trace log made it diagnosable in minutes; the fix, shipped in Webhook Actions 2.2.1, is a conservative JSON repair that: - appends the missing closers only for a purely _structural_ cut — never inside a string, and only after a complete value; - still fails safe when real content was lost, because a force-closed partial plan is worse than a lost turn; - records the provider's `finishReason` on every model call, so truncation and token exhaustion are never confused again. / The bug ## What does a **two-byte truncation** look like in production? The prompt was the most ordinary request our agent handles: _"When a Gravity Forms form is submitted, send the entry as JSON to my webhook."_ The model did the right thing — it decided to search the site's action hooks before proposing anything. Then the turn simply vanished: instead of running the read and continuing, the chat printed the model's raw JSON as plain text and stopped. We ran the identical prompt again. Identical failure. The AI Dev Trace showed why: Trace — response\_raw ends two bytes early (both calls) ``` { "assistant_message": "I am going to search for Gravity Forms triggers on your site…", "reads": [ { "ability": "list_triggers", "input": { "search": "gform" } } ] // ← the reply ends HERE. The root "}" never arrived. // trace metadata, identical on both calls: "parsed_ok": false, "temperature": 0.2 ``` Everything about the reply is correct — the message, the read request, the nesting — except that it is two bytes short: a newline and the root `}`. And it happened at the _same structural position_ in two independent API calls. The request used `responseMimeType: "application/json"` — Gemini's JSON mode, the setting whose entire job is to guarantee parseable output. Deterministic truncation is a diagnosis in itself. At temperature 0.2 with an identical prompt the token sequence is near-deterministic, so whatever ends generation early reproduces at the same offset. A flaky network or a token-budget stop would cut at different places; the same two bytes missing twice points at generation. First rule of this bug class: it is not your URL, not your prompt, and not random — and it will happen again on the next identical request. / The lost turn ## Why did a 99.5%-complete reply cost **the whole turn**? Our agent parses model replies through a ladder: try a straight `json_decode`, then look for a fenced block, then run a conservative repair pass that fixes the quirks models actually emit — a stray trailing brace, a dangling comma before a closer, raw newlines inside string values. That repair had one deliberate blind spot, documented in its own docblock: _truncated replies fail safe rather than being force-closed_. The reasoning was sound. If a reply is cut mid-plan, force-closing the JSON produces a _smaller plan that looks complete_ — steps silently missing, green checkmarks on a build that can only fail later. We had already been burned by exactly that shape of failure once ([the capability-drift bug](https://wpwebhooks.org/blog/ai-agent-capability-drift/)), so "never fabricate a complete-looking document" was a hard rule. But the rule was too blunt. It treated every unterminated object as potentially lossy, including the one case that is provably lossless: a reply where _nothing but closing brackets_ is missing. So a reply that was 99.5% delivered fell through the whole ladder, and the fallback did the only honest thing left — showed the raw text as a plain message with no reads and no plan. Turn lost. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / The fix ## When is force-closing truncated JSON **provably safe**? The repair scanner already walks the text character by character, tracking string literals and escape sequences so brackets inside strings don't affect nesting. The fix extends it to remember the _stack_ of open structures — which closers are owed, in which order. When the text runs out with structures still open, it now force-closes, but only when two conditions both hold: The two guard conditions for a structural cut ``` 1. The scan must NOT end inside a string literal. A cut mid-string means content was lost. Fail safe. 2. The last emitted token must already be a COMPLETE value: a closed string, "}", "]", true / false / null, or a finished number. Ends with , : { [ → a value was coming and never arrived. Fail safe. Ends with "…" } ] → only the closers are missing. Append them. ``` Under those guards, appending the owed closers — `]` then `}` in our field case — reconstructs exactly the document the model produced, byte for byte plus the brackets it owed. Nothing is invented; no plan step can be silently dropped, because a cut that could have lost a step (after a comma, after a colon, inside a value) still fails safe. The two failure philosophies coexist: lossless recovery for structural cuts, honest refusal for lossy ones. The regression suite pins both sides: the exact truncated envelope from the field trace parses with its reads intact, while mid-string, after-comma, after-colon, after-opener, and mid-literal cuts all still return null — alongside every pre-existing repair behavior (stray trailing braces, dangling commas, unescaped newlines). ![Cyberpunk illustration titled “The Reply That Was Two Bytes Short — Repairing Truncated LLM JSON”: a neon-green holographic panel in a server room shows a JSON reply whose right edge is shattering apart, with the labels “missing data / 2 BYTES SHORT” and an error line reading “ERROR: EXPECTED 2 BYTES”, while an engineer types at a glowing console.](https://wpwebhooks.org/blog/gemini-json-mode-truncated-response/og_image.jpg) / Finish reasons ## How do you tell truncation from **token exhaustion**? The second change is observability. Every provider reports why generation stopped — Gemini's `candidates[0].finishReason`, Anthropic's `stop_reason`, OpenAI's `choices[0].finish_reason` — and we were throwing that field away. Without it, a truncated reply in the trace is ambiguous: did the model emit broken JSON, or did we cut it off with a token budget? Those are different bugs with different fixes, and telling them apart previously required reasoning about latencies and reply lengths. Every transport now surfaces the provider's finish reason, and the AI Dev Trace records it as `finish_reason` next to `parsed_ok` and the raw response. A `STOP` with missing brackets is the model's quirk; a `MAX_TOKENS` is ours. One field, one glance. / Takeaways ## What should any **LLM-parsing pipeline** take from this? **1\. JSON mode is a strong hint, not a contract.** Constrained decoding eliminates prose wrappers and code fences, but a structurally incomplete reply is still possible. If your parser's failure mode for that case is "discard the turn", you will eventually discard turns that were 99.5% delivered. **2\. Repair must be lossless or refuse.** The dangerous repairs are the ones that can fabricate a smaller-but-valid document. Gate any force-close on proof that only structure was lost — and keep failing safe everywhere else. A lost turn annoys the user; a silently thinned agent plan ships something broken with checkmarks on it. **3\. Log the raw reply and the finish reason before parsing.** Truncation is invisible in parsed output, because the parse failed. The verbatim `response_raw` plus the provider's finish reason turned this from "the AI randomly breaks sometimes" into a two-trace diagnosis and a same-day fix. The fix ships in Webhook Actions 2.2.1 — the full entry is in the [changelog](https://wpwebhooks.org/changelog/). For the architecture this parser lives in, see [how the in-admin agent is built](https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/) and [the earlier post-mortem](https://wpwebhooks.org/blog/ai-agent-capability-drift/) that shaped its fail-safe philosophy. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Gemini JSON Mode Can Drop the Final Closing Brace","description":"A real bug post-mortem: Gemini’s JSON mode returned our agent’s envelope without its closing brace — twice, deterministically — and the conservative repair that recovers it without ever inventing content.","datePublished":"2026-07-17","dateModified":"2026-07-17","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/gemini-json-mode-truncated-response/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/gemini-json-mode-truncated-response/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration titled “The Reply That Was Two Bytes Short — Repairing Truncated LLM JSON”: a neon-green holographic panel in a server room shows a JSON reply whose right edge is shattering apart, with the labels “missing data / 2 BYTES SHORT” and an error line reading “ERROR: EXPECTED 2 BYTES”, while an engineer types at a glowing console."},"keywords":["gemini json mode truncated","gemini missing closing brace","llm truncated json response","llm json repair","gemini response mime type application json","finishreason gemini","malformed json llm output"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Gemini JSON Mode Can Drop the Final Closing Brace","item":"https://wpwebhooks.org/blog/gemini-json-mode-truncated-response/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does Gemini’s JSON mode guarantee syntactically valid JSON?","acceptedAnswer":{"@type":"Answer","text":"Mostly, but not absolutely. Setting responseMimeType to application/json constrains decoding and eliminates prose wrappers and markdown fences, but flash-class models can still emit a reply cut off before the final closing brace or bracket. Any parser consuming JSON-mode output in production needs a strategy for a structurally incomplete reply — either a conservative repair or an explicit retry."}},{"@type":"Question","name":"Why did the truncation happen at exactly the same spot twice?","acceptedAnswer":{"@type":"Answer","text":"Low temperature. At temperature 0.2 with an identical prompt, the model’s token sequence is near-deterministic, so whatever caused the early stop reproduced at the same structural position in both calls. Deterministic truncation is a useful diagnostic signal: a random network problem or token-limit stop would cut at different offsets, while the same two bytes missing twice points at the generation itself."}},{"@type":"Question","name":"When is it safe to auto-close truncated JSON from an LLM?","acceptedAnswer":{"@type":"Answer","text":"Only when the cut is purely structural: the scan must not end inside a string literal, and the last emitted token must already be a complete value — a closed string, object, array, number, or literal. Then appending the missing closers restores exactly what the model produced, losing nothing. A cut after a comma, colon, or opening bracket means a value was lost, and force-closing there fabricates a smaller document that looks complete — the most dangerous possible outcome for an agent plan."}},{"@type":"Question","name":"What is finishReason and why log it?","acceptedAnswer":{"@type":"Answer","text":"Every major provider reports why generation stopped: Gemini’s candidates[0].finishReason, Anthropic’s stop_reason, OpenAI’s choices[0].finish_reason. STOP means the model chose to end; MAX_TOKENS/length means the output was cut by budget. Logging it per call costs one field and instantly separates “the model produced broken JSON” from “we truncated the model” — two failures with completely different fixes."}},{"@type":"Question","name":"How do I debug truncated LLM output in production?","acceptedAnswer":{"@type":"Answer","text":"Record the raw response verbatim before any parsing or repair, alongside the parse outcome and the provider’s finish reason. Truncation bugs are invisible in parsed output — by definition the parse failed — so the raw string is the only evidence. Our AI Dev Trace stores response_raw, parsed_ok, and finish_reason per model call, which is exactly what made this bug diagnosable from two log entries."}}]} ``` --- --- title: "ivyforms/form/after_submission Webhook: Hook Reference" description: "ivyforms/form/after_submission documentation: hook parameters, numeric field IDs, a wp_remote_post code example, and reliable async webhook dispatch." url: "https://wpwebhooks.org/blog/ivyforms-after-submission-webhook/" date: "2026-07-17" --- # ivyforms/form/after_submission Webhook: Hook Reference TL;DR - `ivyforms/form/after_submission` fires after each successful IvyForms submission with four arguments: `$formId`, `$submissionData`, `$formFields`, `$entryId` - Submitted values are keyed by **numeric field ID** — join them against the `$formFields` field objects (`getId()` + label getter) to get human-readable keys - Inline `wp_remote_post` works in dev but loses events in production — any timeout or endpoint failure silently drops the submission; dispatch from a queue instead / The Hook ## What does **ivyforms/form/after\_submission** do? `ivyforms/form/after_submission` is the action hook IvyForms fires after a form submission has passed validation and been processed. It is the correct place to trigger outbound integrations — webhooks, CRM syncs, notifications — that depend on the complete submitted data set. IvyForms has no built-in webhook feature, so this hook is the extension point for sending submissions anywhere outside WordPress. By the time your callback runs you have the form ID, every submitted value, the full field definitions, and — when entry storage is enabled — the stored entry ID. A companion hook, `ivyforms/form/before_submission`, fires earlier in the same request, before the submission is processed. For webhook dispatch, `after_submission` is the safer choice: the data is final and the entry (if stored) already exists. / Parameters ## What **parameters** does the hook pass? The hook passes four arguments. The `add_action` call must specify the priority (10 is the default) and the accepted argument count (4) for all of them to arrive in your callback. Hook signature ``` add_action( 'ivyforms/form/after_submission', function( $form_id, $submission_data, $form_fields, $entry_id ) { // All four arguments are available here }, 10, 4 ); ``` - **`$form_id`** — the numeric ID of the submitted form - **`$submission_data`** — an associative array of submitted values keyed by **numeric field ID** as string keys (`'1'`, `'2'`, `'3'`…), plus three metadata keys: `formId`, `postId` (the page the form was embedded on), and `referer` - **`$form_fields`** — an array of `IvyForms\Entity\Field\Field` entity objects exposing each field definition through getters: `getId()`, `getType()`, and — via `getFieldGeneralSettings()` — `getLabel()` and `isRequired()`. The submitted value is _not_ on the object; you join it from `$submission_data` by ID - **`$entry_id`** — the ID of the stored entry, or `null` when entry storage is disabled in IvyForms The numeric keying is the detail that surprises most developers: `$submission_data` does not contain `'name'` or `'email'` keys. Field labels live only in `$form_fields`. IDs stay stable when labels are edited, which is why IvyForms keys by ID — but it means any downstream consumer either works with numeric keys or joins the two arrays first. / Basic Implementation ## How do you send submissions to a **webhook with raw PHP**? The minimal implementation hooks `ivyforms/form/after_submission`, builds a payload from the submitted values, and calls `wp_remote_post()`. This works in local development and for low-stakes forms where a dropped delivery is acceptable. functions.php — minimal ivyforms webhook ``` add_action( 'ivyforms/form/after_submission', function( $form_id, $submission_data, $form_fields, $entry_id ) { $payload = [ 'form_id' => $form_id, 'entry_id' => $entry_id, 'name' => $submission_data['1'] ?? '', 'email' => $submission_data['2'] ?? '', 'message' => $submission_data['3'] ?? '', ]; wp_remote_post( 'https://your-n8n-url/webhook/test', [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $payload ), 'timeout' => 10, ] ); // No retry. No queue. No log. Event is gone if this fails. }, 10, 4 ); ``` The hard-coded field IDs (`'1'`, `'2'`, `'3'`) are the fragile part — they map to whatever fields hold those IDs in the form builder. The next section shows how to avoid hard-coding them. / Field IDs ## How do you map **numeric field IDs to labels**? `$form_fields` exists precisely for this join. Each `Field` object describes one field — read its ID with `getId()` and its label through the general-settings accessor. Instead of hard-coding ID keys, iterate the definitions and build a payload keyed by label: functions.php — payload keyed by field labels ``` add_action( 'ivyforms/form/after_submission', function( $form_id, $submission_data, $form_fields, $entry_id ) { $payload = [ 'form_id' => $form_id, 'entry_id' => $entry_id, ]; foreach ( $form_fields as $field ) { // "Full Name" → "full_name" $label = $field->getFieldGeneralSettings()->getLabel(); $key = str_replace( '-', '_', sanitize_title( $label ) ); // Submitted value lives in $submission_data, keyed by field ID $payload[ $key ] = $submission_data[ (string) $field->getId() ] ?? null; } wp_remote_post( 'https://your-n8n-url/webhook/test', [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $payload ), 'timeout' => 10, ] ); }, 10, 4 ); ``` For a contact form with Name (ID 1), Email (ID 2), and Message (ID 3), the join produces `{"name": "…", "email": "…", "message": "…"}` regardless of which IDs the form builder assigned. If a form is later edited and a field gets a new ID, the label-based payload stays stable — the numeric-key payload silently shifts meaning. Two details worth noting. The submitted values are keyed by field ID as _string_ keys, so cast `getId()` to string for the lookup. And `sanitize_title()` produces dash-separated slugs (`full-name`), so the `str_replace` turns them into the underscore keys most receivers expect. The same numeric-ID structure appears when the submission leaves WordPress as a webhook: the raw payload carries `$submission_data` under `args[1]` and the field definitions under `args[2]` — already normalized to plain `{id, label, type, required, value}` objects with the submitted value merged in, so receivers like n8n can do the identical join on the other side. The [IvyForms webhook example](https://wpwebhooks.org/examples/ivyforms-to-webhook/) shows that payload end-to-end and a no-code way to flatten it with field mapping. / Production Reality ## Why does the **raw PHP approach break** in production? The raw implementation has three structural problems that only surface under production conditions. **1\. Inline execution blocks the visitor.** The hook runs synchronously inside the request that processed the form submission. `wp_remote_post()` holds the connection open until the endpoint responds or the timeout expires — a slow endpoint means a visibly slow form. **2\. Timeouts and downtime permanently lose the submission.** `wp_remote_post()` defaults to a 5-second timeout. An endpoint restart, a 5xx during a deployment, a rate limit during a campaign burst — each one drops the event with no retry and no recovery path. **3\. The failure is invisible.** WordPress does not log outbound HTTP calls by default. A failed delivery produces no admin error and no alert; you find out when someone asks why their message was never answered. | Scenario | Inline wp\_remote\_post | Queued async dispatch | | --- | --- | --- | | Endpoint timeout (5s) | Submission lost permanently | Retried with exponential backoff | | Endpoint 503 (server error) | Submission lost permanently | Retried up to 5 times (1m, 2m, 4m, 8m) | | Endpoint down for 2 hours | All submissions during window lost | Queue drains when endpoint recovers | | Delivery failure visibility | None | Per-attempt log with status codes | | Visitor form experience | Blocks until endpoint responds | Instant — dispatch happens in background | The fix is the same as for every WordPress form plugin: record the event first, deliver it second. Enqueue the payload at hook time into a persistent queue, return the form response immediately, and let a background worker attempt delivery with retries and per-attempt logging. Why inline delivery fails silently — and what a production-grade queue looks like — is covered in [Why WordPress Webhooks Silently Fail in Production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) and [Why WP-Cron Is Not Enough for Production Webhook Delivery](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/). For the no-code version of this integration — trigger selection, field mapping, and an event log with replay — see the [IvyForms webhook example](https://wpwebhooks.org/examples/ivyforms-to-webhook/). try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"ivyforms/form/after_submission Webhook: Hook Reference","description":"ivyforms/form/after_submission documentation: hook parameters, numeric field IDs, a wp_remote_post code example, and reliable async webhook dispatch.","datePublished":"2026-07-17","dateModified":"2026-07-17","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/ivyforms-after-submission-webhook/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["ivyforms form after submission","ivyforms after submission hook","ivyforms webhook","ivyforms hook parameters","ivyforms field ids","ivyforms n8n","wordpress ivyforms hooks"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"ivyforms/form/after_submission Webhook: Hook Reference","item":"https://wpwebhooks.org/blog/ivyforms-after-submission-webhook/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What parameters does ivyforms/form/after_submission pass?","acceptedAnswer":{"@type":"Answer","text":"The hook passes four arguments: $formId (the numeric form ID), $submissionData (an array of submitted values keyed by numeric field ID, plus formId, postId, and referer), $formFields (an array of IvyForms Field entity objects exposing getId(), getType(), and the label via getFieldGeneralSettings()->getLabel()), and $entryId (the stored entry ID, or null when entry storage is disabled). Register your callback with add_action('ivyforms/form/after_submission', $callback, 10, 4) so all four arrive."}},{"@type":"Question","name":"How do I send IvyForms submissions to n8n?","acceptedAnswer":{"@type":"Answer","text":"Hook into the ivyforms/form/after_submission action (4 args: $formId, $submissionData, $formFields, $entryId), then post the data to your n8n webhook URL using wp_remote_post(). Note that $submissionData keys are numeric field IDs — use $formFields to map them to labels. For reliable delivery with retries, dispatch from a persistent queue instead of inline, so failures are retried with exponential backoff rather than lost."}},{"@type":"Question","name":"Why are IvyForms submission values keyed by numbers instead of field names?","acceptedAnswer":{"@type":"Answer","text":"IvyForms stores submitted values under each field's numeric ID — $submissionData['1'], $submissionData['2'], and so on — because labels can change while IDs stay stable. The $formFields argument carries the matching Field objects — read the ID with getId() and the label with getFieldGeneralSettings()->getLabel() — so you can join by ID to produce a payload with human-readable keys."}},{"@type":"Question","name":"Does ivyforms/form/after_submission fire when entry storage is disabled?","acceptedAnswer":{"@type":"Answer","text":"Yes. The hook fires on every successful submission regardless of the entry storage setting. When entry storage is disabled in IvyForms, the fourth argument ($entryId) is null instead of a numeric entry ID — treat it as optional in your callback."}},{"@type":"Question","name":"Does a webhook inside ivyforms/form/after_submission block the form submission?","acceptedAnswer":{"@type":"Answer","text":"Yes, if you call wp_remote_post() directly. The hook runs synchronously inside the request that processed the submission, so PHP waits for the remote endpoint to respond (default timeout: 5 seconds) before the visitor sees the confirmation. Moving dispatch into a queued background job returns the response instantly and makes failures retryable."}}]} ``` --- --- title: "AI Agent Capability Drift: When the Prompt Lies About Tools" description: "A real bug post-mortem: a class_exists check made our agent’s prompt advertise tools the executor silently dropped — and the three fixes that shipped in 2.2.0." url: "https://wpwebhooks.org/blog/ai-agent-capability-drift/" date: "2026-07-14" --- # AI Agent Capability Drift: When the Prompt Lies About Tools **TL;DR:** We shipped a bug where our in-admin AI agent's system prompt claimed tools the executor could not run — and the failure was completely silent. Three lessons from the fix, now in Webhook Actions 2.2.0: - Derive every capability claim in the prompt from the _same catalog the executor runs_ — never from a proxy like `class_exists`. - Never silently drop a plan step the model proposed. A thinned plan looks complete and ships broken; surface the drop to the user. - Make the agent _read_ API contracts at build time instead of recalling them from training data — a one-call schema read ended an entire class of hallucinated fields. / The failure ## How does an AI agent build **break with no error**? The request was ordinary: _"create a new user from a CF7 submission, use the WP REST API."_ The agent behind [Build with AI](https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/) did everything right. It read the captured Contact Form 7 payload, read the `POST /wp/v2/users` contract, saw that `password` is required but absent from the form, and proposed a six-step plan — including a Code Glue snippet¹ that generates the password before dispatch. Then the trace log told a different story: Trace — the model proposed 6 steps; the plan kept 4 ``` // response_raw: the model's plan (abridged) "plan": [ { "id": "step_1", "ability": "create_webhook", ... }, { "id": "step_2", "ability": "assign_credential", ... }, { "id": "step_3", "ability": "set_mapping", ... }, { "id": "step_4", "ability": "create_snippet", ... }, // ← dropped { "id": "step_5", "ability": "assign_snippet", ... }, // ← dropped { "id": "step_6", "ability": "test_dispatch", ... } ] // what the executor recorded for the same turn "parsed_ok": true, "plan_steps": 4 ``` Two steps vanished between the model and the plan review — the two that injected the required password. Every surviving step ran green. The user got a finished build with checkmarks that could only ever answer `400 missing password` on a live delivery. No exception, no log entry, nothing to bisect. That is the worst kind of bug an agent can have: the system did exactly what it was written to do. / Root cause ## Where did the **capability lie** come from? The dropped abilities belong to our Pro plugin, which registers them into the agent's ability catalog through a filter. On this site, Pro had quietly deactivated itself: its bootstrap requires a minimum version of the free plugin and bails out at `plugins_loaded` when the check fails. So far so good — defensive version gating working as designed. The lie lived one layer up. The system prompt decides whether to tell the model "Pro Code Glue is available" with a capability check — and that check used `class_exists` on a Pro class. But Pro's Composer autoloader loads unconditionally, before the version gate. The class existed; the plugin behind it wasn't running: PHP — the check that lied, and the fix ``` // BEFORE — true even when Pro bailed out at plugins_loaded: // the autoloader is registered before the version gate, so the // class exists while the ability filter never ran. private function proIsActive(): bool { return class_exists( 'Pro\License\LicenseManager' ) && ( new LicenseManager() )->isActive(); } // AFTER — ask the executable catalog itself. create_snippet is // registered exactly when Pro booted AND holds an active license, // so the prompt can no longer promise what the executor lacks. private function proIsActive(): bool { return isset( $this->registry->definitions()['create_snippet'] ); } ``` The result of the mismatch: the prompt advertised snippet abilities that were not in the catalog, the model (correctly!) planned with them, and the plan normalizer — which validates each step against the catalog — discarded the unknown steps on the way in. The general shape of this bug is worth naming: **capability drift**. Any agent whose prompt is assembled from one code path and whose tools are registered by another will eventually see the two disagree — a plugin deactivates, a license expires, a feature flag flips, a module fails to boot. The only durable fix is to make the prompt a _projection of the tool registry_, never a parallel account of it. > If the executor can't run it, the prompt must not promise it. Capability claims are derived from the tool catalog — the same array the executor validates against — or they are lies waiting for a boot-order bug. — the rule we now build by / Silent drops ## Why is silently dropping a step **worse than failing**? Our plan normalizer was written defensively, and reasonably so: a language model can name a tool that never existed, so unknown abilities were skipped. The mistake was skipping them _silently_. A plan is not a list of independent conveniences — steps depend on each other. Drop the snippet that injects `password` and the surviving `create_webhook` + `set_mapping` steps aren't a smaller version of the build; they're a different, broken build wearing the same name. Failing loudly would have surfaced this in the first test run. Instead the thinned plan executed cleanly and the defect moved downstream — to a live form submission, days later, on someone else's site. The fix keeps the defensive skip but makes it visible: the normalizer records every dropped ability name, and the turn shows a notice in chat before anyone runs the plan: Chat notice — what the user now sees instead of nothing ``` Some proposed steps were removed because this site cannot run them: create_snippet, assign_snippet. The remaining plan may be incomplete. If these are Code Glue abilities, make sure Webhook Actions Pro is installed, up to date and licensed. ``` The same notice catches genuinely hallucinated tool names — the case the silent skip was originally written for — which previously also vanished without a trace. ![Cyberpunk illustration of a developer at a wall of code terminals facing a giant robotic arm that is sparking and breaking apart, tagged with red error labels reading "TOOL NOT FOUND", "INTERFACE FAULT" and "PROMPT INCONSISTENCY", under the neon headline "When your AI agent’s prompt lies about its tools".](https://wpwebhooks.org/blog/ai-agent-capability-drift/og_image.jpg) / Read, don't recall ## Should the agent **read contracts or recall them**? The password bug had a prequel. Weeks earlier, the same build request produced a plan with no password handling at all — because the model recalled the `wp/v2/users` contract from training data and recalled it wrong. Models are confident about popular APIs precisely because they've seen thousands of near-copies, current or not. The fix was to stop asking the model to remember and give it a way to look: a `get_rest_route_schema` read ability that performs an internal `OPTIONS` request on the site's own REST route and returns the self-declared argument list with required flags — about 1.3 KB for `wp/v2/users`. The system prompt makes the read mandatory before any internal build. Since then, every trace shows the same pattern: the model reads the contract, sees `required: ["username", "email", "password"]`, and plans around reality. The honest-refusal path is the part we're proudest of. With Pro genuinely absent, the prompt now says so truthfully — and the model does exactly what it's told: Trace — the free-tier plan, after the fix (abridged) ``` "The WordPress REST API requires a 'password' field to create a new user, but your Contact Form 7 submission does not provide one. On the Free plugin tier, I cannot inject a generated or static password. This means the user creation will likely fail [...] you would need Webhook Actions Pro with Code Glue." plan: create_webhook (disabled) → assign_credential → set_mapping plan_steps: 3 — nothing dropped, nothing promised, nothing fake ``` Same model, same request. The only variable is whether the prompt tells the truth about what the site can do — and a truthful prompt turns out to be the difference between a broken build with green checkmarks and an honest plan with a clear upgrade path. | Failure mode | Silent (before) | Loud (after) | | --- | --- | --- | | Prompt vs tools disagree | class\_exists proxy — prompt advertises tools the executor lacks | Capability claims derived from the executable ability catalog | | Model proposes unknown step | Step skipped without a trace; plan looks complete | Step still skipped, but named in a visible chat notice | | API contract knowledge | Recalled from training data, confidently wrong | Read per-build via an internal OPTIONS schema request | | Missing required field | Discovered as a 400 on a live delivery, days later | Handled in the plan — snippet on Pro, honest refusal on Free | → All three fixes shipped in [Webhook Actions 2.2.0](https://wpwebhooks.org/wordpress-webhook-plugin/): catalog-derived capability claims, dropped-step notices, and contract reads for internal REST automations — alongside confirm gates for test deliveries that create real data. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) /Footnotes ¹ Code Glue runs a small PHP snippet on the payload before dispatch — the supported way to inject a computed value (like a generated password) that the triggering event does not carry. ² The Webhook Actions plugin on WordPress.org: [wordpress.org/plugins/flowsystems-webhook-actions](https://wordpress.org/plugins/flowsystems-webhook-actions/). The plan-first agent loop and its trace log are covered in [the architecture article](https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"AI Agent Capability Drift: When the Prompt Lies About Tools","description":"A real bug post-mortem: a class_exists check made our agent’s prompt advertise tools the executor silently dropped — and the three fixes that shipped in 2.2.0.","datePublished":"2026-07-14","dateModified":"2026-07-14","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/ai-agent-capability-drift/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/ai-agent-capability-drift/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of a developer at a wall of code terminals facing a giant robotic arm that is sparking and breaking apart, tagged with red error labels reading \"TOOL NOT FOUND\", \"INTERFACE FAULT\" and \"PROMPT INCONSISTENCY\", under the neon headline \"When your AI agent’s prompt lies about its tools\"."},"keywords":["ai agent capability drift","ai agent tool hallucination","llm agent silent failure","ai agent system prompt tools","llm dropped tool calls","ai agent tool catalog","wordpress ai agent bugs"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"AI Agent Capability Drift: When the Prompt Lies About Tools","item":"https://wpwebhooks.org/blog/ai-agent-capability-drift/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is capability drift in an AI agent?","acceptedAnswer":{"@type":"Answer","text":"Capability drift is when an agent’s system prompt and its executable tool registry disagree about what the agent can do. It happens whenever the prompt is assembled by one code path and the tools are registered by another — a plugin deactivates, a license expires, a module fails to boot — and the prompt keeps advertising tools the executor no longer has. The durable fix is deriving every capability claim in the prompt from the same catalog the executor validates against."}},{"@type":"Question","name":"Why is class_exists a bad capability check in WordPress?","acceptedAnswer":{"@type":"Answer","text":"Because Composer autoloaders register classes before a plugin’s bootstrap logic runs. A plugin that bails out at plugins_loaded — a version gate, a missing dependency — leaves its classes loadable while none of its hooks or filters ever registered. class_exists then reports capability that does not exist at runtime. Check for the actual registered behavior instead: a hook, a filter result, or an entry in the registry the feature populates."}},{"@type":"Question","name":"What should happen when an LLM proposes a tool that does not exist?","acceptedAnswer":{"@type":"Answer","text":"Validate the step against the tool catalog and skip it — but never silently. Record the dropped tool names and show the user a notice before the plan runs. Plan steps depend on each other, so a silently thinned plan is not a smaller version of the build; it is a different, broken build that looks complete and fails downstream where it is hardest to debug."}},{"@type":"Question","name":"How do you stop an AI agent hallucinating REST API fields?","acceptedAnswer":{"@type":"Answer","text":"Give it a read tool that fetches the route’s self-declared contract at build time — in WordPress, an internal OPTIONS request returns every argument with its required flag — and make the system prompt mandate that read before any internal build. Models recall popular API shapes from training data, which is confidently wrong often enough that recalling should never be the default."}},{"@type":"Question","name":"Is an honest refusal better than a best-effort AI build?","acceptedAnswer":{"@type":"Answer","text":"Yes. With truthful capability context, the model states the limitation, proposes only the steps the site can actually run, and names the upgrade path — the user decides with full information. A best-effort build that silently omits a required piece ships green checkmarks and fails days later on a live event, which costs far more trust than a plain “this tier cannot do that”."}}]} ``` --- --- title: "as_schedule_single_action: Signature & Parameters" description: "as_schedule_single_action documentation: full PHP signature, the timestamp and $unique parameters, return value, and code examples for delayed jobs." url: "https://wpwebhooks.org/blog/as-schedule-single-action-reference/" date: "2026-07-10" --- # as_schedule_single_action: Signature & Parameters **TL;DR:** `as_schedule_single_action()` queues a one-time background job that fires at a specific Unix timestamp by calling `do_action( $hook, ...$args )`. - Signature: `as_schedule_single_action( int $timestamp, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 )` - Returns the integer action ID from `wp_actionscheduler_actions`; with `$unique = true` it returns the existing ID instead of inserting a duplicate. - It is the persistent, retryable equivalent of `wp_schedule_single_event()` — backed by database tables, not the `cron` option. / Signature ## What is the **as\_schedule\_single\_action** signature? [`as_schedule_single_action()`](https://actionscheduler.org/api/#function-reference--as_schedule_single_action) schedules one background action to run once, at or after a given Unix timestamp. It is a global function shipped with [Action Scheduler](https://actionscheduler.org/), the background-job library maintained by WooCommerce and bundled inside WooCommerce, Jetpack, and hundreds of other plugins. When the scheduled time arrives, the queue runner calls `do_action( $hook, ...$args )`, so the code that actually runs is an ordinary WordPress hook callback registered with [`add_action()`](https://developer.wordpress.org/reference/functions/add_action/). PHP — as\_schedule\_single\_action full signature ``` as_schedule_single_action( int $timestamp, // Unix time of the earliest run string $hook, // action hook fired via do_action() array $args = [], // args spread into the callback string $group = '', // label for filtering / bulk cancel bool $unique = false, // skip if an identical action is pending int $priority = 10 // 0-255, lower runs first (AS 3.5+) ): int; // returns the action ID, or 0 on failure ``` The `$unique` parameter was added in Action Scheduler 3.3.0 and `$priority` in 3.5.0. On older bundled versions those trailing arguments are simply ignored, so passing them is safe but has no effect until the host site ships a recent enough copy. / Parameters ## What does each **as\_schedule\_single\_action** parameter do? The first two arguments are required; the rest are optional. Getting `$args` and `$group` right matters most, because they are what the deduplication and cancellation functions match on. | Parameter | Type | Purpose | | --- | --- | --- | | `$timestamp` | int | Earliest Unix time to run. The action fires on the first queue pass at or after this moment — not exactly on it. | | `$hook` | string | The action hook name. Register a callback with `add_action( $hook, … )` or nothing runs. | | `$args` | array | Values spread into the callback as `do_action( $hook, ...$args )`. Keep it small — args are serialised into the row. | | `$group` | string | Free-text label used to filter in the admin UI and to bulk-cancel with `as_unschedule_all_actions()`. | | `$unique` | bool | When true, skip insertion if a pending/running action with the same hook, args and group already exists. | | `$priority` | int | 0–255 (default 10). Lower numbers are claimed first within the same timestamp. Added in AS 3.5.0. | / Timestamp ## How precise is the **$timestamp** argument? The timestamp is a floor, not an alarm clock. Action Scheduler runs a queue on each WP-Cron pass (or Action Scheduler's own runner), claims a batch of actions whose `scheduled_date` is in the past, and executes them. So an action set for `time() + 5` runs on the first queue pass after those five seconds — which could be a few seconds late on a busy site, or longer if no traffic triggers WP-Cron. If you need clock-aligned execution (always at 06:00), use `as_schedule_cron_action()` instead; if you need it to run as soon as possible with no delay target at all, use [`as_enqueue_async_action()`](https://wpwebhooks.org/blog/action-scheduler-api-functions/). FIG 01 — Lifecycle of a single scheduled action / Return value ## What does **as\_schedule\_single\_action** return? It returns the integer action ID — the primary key of the new row in the `wp_actionscheduler_actions` table. Store it if you plan to cancel or inspect the action later. Two edge cases change what you get back: 1. **Duplicate skipped.** When `$unique = true` and a matching pending or in-progress action already exists, the call returns that _existing_ action's ID and inserts nothing. 2. **Failure.** If the action store cannot save the row, the function returns `0`. Treat a falsy return as "not scheduled" rather than assuming success. > Every scheduled action maps to a WordPress hook. When the action runs, Action Scheduler calls `do_action($hook, ...$args)` — the callback is just a normal hook handler. — Action Scheduler usage guide / Deduplication ## How does **$unique** prevent duplicate single actions? Passing `$unique = true` makes the scheduling call idempotent: before inserting, Action Scheduler looks for a pending or running action with the same hook, the same serialised `$args`, and the same group. If one exists, no new row is created. This is the race-safe way to guarantee "at most one queued job" for an event, because the check and the insert happen inside the same call rather than in your application code. The matching is exact, though — a different argument value, order, or type counts as a distinct action. For the guard-based alternative and the exact matching rules, see [preventing duplicate scheduled actions](https://wpwebhooks.org/blog/action-scheduler-prevent-duplicate-actions/). PHP — schedule one delayed, deduplicated webhook ``` // Deliver a webhook 30s after checkout — but only once per order $action_id = as_schedule_single_action( time() + 30, 'myplugin_deliver_order_webhook', [ 'order_id' => $order_id ], 'order-webhooks', true // unique: repeated saves won't queue a 2nd delivery ); add_action( 'myplugin_deliver_order_webhook', function( $order_id ) { $order = wc_get_order( $order_id ); wp_remote_post( 'https://example.com/hook', [ 'body' => wp_json_encode( $order->get_data() ), ] ); } ); ``` / vs WP-Cron ## How is it different from **wp\_schedule\_single\_event**? Both schedule a one-time hook, but the storage and reliability differ sharply. [`wp_schedule_single_event()`](https://developer.wordpress.org/reference/functions/wp_schedule_single_event/) stores every event in a single serialised `cron` row in `wp_options`, runs on visitor page loads, keeps no history, and never retries. `as_schedule_single_action()` stores one row per action, is claimed by a dedicated queue runner, retries failures, and logs every attempt. | Feature | wp\_schedule\_single\_event | as\_schedule\_single\_action | | --- | --- | --- | | Storage | One serialised cron option row | One DB row per action, indexed | | Trigger | Visitor page loads (WP-Cron) | Dedicated queue runner batch | | Retry | None — one attempt | Automatic retry on failure | | History | None once it runs | Full attempt log in wp-admin | | Dedup | Manual — read the cron array | $unique = true, race-safe | | Cancel | wp\_unschedule\_event by timestamp | as\_unschedule\_action by hook/args/group | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / In practice ## Do you call **as\_schedule\_single\_action** for webhooks directly? Usually not. Firing an outbound webhook when a WordPress or WooCommerce event happens is _event-driven_, not time-delayed: you hook `do_action` and POST on the event itself, ideally behind a queue that adds retry with exponential backoff and a delivery log. You reach for `as_schedule_single_action()` in your own code when you need a _delayed_ one-off job that is not a webhook: a reminder email, a cleanup pass, a deferred API sync. For the full function family — async, recurring, cron, query and cancel — see the [Action Scheduler PHP API reference](https://wpwebhooks.org/blog/action-scheduler-api-functions/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"as_schedule_single_action: Signature & Parameters","description":"as_schedule_single_action documentation: full PHP signature, the timestamp and $unique parameters, return value, and code examples for delayed jobs.","datePublished":"2026-07-10","dateModified":"2026-07-10","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/as-schedule-single-action-reference/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["as schedule single action","as schedule single action signature","as schedule single action parameters","action scheduler single action","as schedule single action unique","as schedule single action return value"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"as_schedule_single_action: Signature & Parameters","item":"https://wpwebhooks.org/blog/as-schedule-single-action-reference/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is the signature of as_schedule_single_action?","acceptedAnswer":{"@type":"Answer","text":"as_schedule_single_action( int $timestamp, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ) and it returns the integer action ID. The first two arguments — the Unix timestamp and the hook name — are required; the rest are optional. $unique was added in Action Scheduler 3.3.0 and $priority in 3.5.0."}},{"@type":"Question","name":"What does as_schedule_single_action return?","acceptedAnswer":{"@type":"Answer","text":"It returns the integer action ID — the primary key of the new row in wp_actionscheduler_actions. When $unique is true and a matching pending or in-progress action already exists, it returns that existing action ID without inserting a duplicate. If the row cannot be saved it returns 0, so a falsy return means the action was not scheduled."}},{"@type":"Question","name":"Does the action run exactly at the timestamp?","acceptedAnswer":{"@type":"Answer","text":"No. The timestamp is the earliest time the action can run, not an exact alarm. Action Scheduler executes it on the first queue pass at or after that moment, which can be a few seconds late on a busy site or longer if no traffic triggers the queue. For clock-aligned execution use as_schedule_cron_action instead."}},{"@type":"Question","name":"How is as_schedule_single_action different from wp_schedule_single_event?","acceptedAnswer":{"@type":"Answer","text":"Both schedule a one-time hook, but as_schedule_single_action stores one indexed row per action in dedicated database tables, is run by a dedicated queue runner, retries on failure, and logs every attempt. wp_schedule_single_event stores events in a single serialised cron option, runs on visitor page loads, keeps no history, and never retries."}},{"@type":"Question","name":"How do I stop as_schedule_single_action creating duplicates?","acceptedAnswer":{"@type":"Answer","text":"Pass $unique = true as the fifth argument. Before inserting, Action Scheduler checks for a pending or running action with the same hook, serialised args, and group; if one exists it returns that ID instead of creating a second row. Because the check and insert happen in one call, it is race-safe."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/as-schedule-single-action.png","caption":"FIG 01 — Lifecycle of a single scheduled action","description":"A call to as_schedule_single_action inserts one pending row in wp_actionscheduler_actions stamped with the target timestamp. Once that time passes the queue runner claims the row, marks it in-progress, and calls do_action on the hook with the stored args. A clean run marks the action complete; a thrown exception marks it failed and Action Scheduler retries it.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress to Zapier Webhook: Trigger Zaps from Any Hook" description: "Connect WordPress to Zapier: send any post, form, or WooCommerce event to a Zap via webhook — Catch Hook setup, payload format, and a self-hosted alternative." url: "https://wpwebhooks.org/blog/wordpress-zapier-webhook/" date: "2026-07-07" --- # WordPress to Zapier Webhook: Trigger Zaps from Any Hook **TL;DR:** Connect WordPress to Zapier by POSTing your site's events to a _Catch Hook_ URL from the Webhooks by Zapier trigger — no polling, no per-Zap connector needed. - Create a "Webhooks by Zapier → Catch Hook" trigger and copy its URL. - Send any WordPress or WooCommerce event to that URL as JSON — a form entry, a new order, a published post. - Because you POST to an arbitrary HTTP endpoint, you avoid Zapier's per-task WordPress connector and keep the trigger self-hosted. / Overview ## How do you connect WordPress to Zapier with a webhook? The most reliable way to trigger a Zap from WordPress is a webhook: your site sends an HTTP POST to a Zapier [Catch Hook](https://help.zapier.com/hc/en-us/articles/8496288690317-Trigger-Zap-workflows-from-webhooks) URL the instant an event happens, and Zapier treats that payload as the trigger. This is push, not poll — Zapier's built-in WordPress app polls your REST API on a schedule and only sees a narrow set of events, whereas a webhook fires immediately for _any_ action on your site. The [Webhooks by Zapier](https://zapier.com/apps/webhook/integrations) trigger is available on paid Zapier plans and is the standard entry point for custom integrations. FIG 01 — WordPress event to Zapier Catch Hook / Catch Hook ## What is a Zapier **Catch Hook** URL? When you add "Webhooks by Zapier" as a Zap's trigger and choose the _Catch Hook_ event, Zapier generates a unique URL that looks like `https://hooks.zapier.com/hooks/catch/123456/abcde/`. Anything POSTed to that URL becomes an incoming trigger event. Zapier parses JSON bodies automatically, exposing each field for use in later Zap steps. You do not authenticate the request in the usual sense — the URL itself is the secret, so treat it like a credential and, if your platform supports it, add a shared-secret header your downstream steps can verify. / Sending events ## How do you send a WordPress event to the Catch Hook? Any code path that produces an HTTP POST works. The direct approach is a [`wp_remote_post()`](https://developer.wordpress.org/reference/functions/wp_remote_post/) call inside a hook callback — but doing it inline blocks the request and has no retry if Zapier is briefly unreachable. The example below shows the raw version so you can see the shape of the payload. PHP — post a WooCommerce order to a Zapier Catch Hook ``` add_action( 'woocommerce_order_status_completed', function( $order_id ) { $order = wc_get_order( $order_id ); wp_remote_post( 'https://hooks.zapier.com/hooks/catch/123456/abcde/', [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'order_id' => $order_id, 'email' => $order->get_billing_email(), 'total' => $order->get_total(), ] ), ] ); } ); ``` That works, but it is synchronous and fire-and-forget: if Zapier returns a 500 or the request times out, the event is lost. Production integrations queue the delivery and retry it, so a transient Zapier failure doesn't drop the event. ![Cyberpunk illustration of a neon WordPress logo linked by a glowing "WEBHOOK" node to a Zapier panel over a night city, with WordPress event labels User Login, Post Published, Form Submitted, and New Order.](https://wpwebhooks.org/blog/wordpress-zapier-webhook/og_image.jpg) try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / No-code path ## How do you do this **without writing code**? A no-code webhook tool turns the same connection into an admin-UI task: paste the Catch Hook URL as the endpoint, pick the WordPress trigger, capture a real example payload, map fields to the JSON shape your Zap expects, and send a test delivery. Any tool that posts to an arbitrary HTTP endpoint works here — the Catch Hook is just a URL, so the same setup targets [Webhooks by Zapier](https://zapier.com/apps/webhook/integrations), n8n, or Make. Look for queued delivery with exponential-backoff retry, plus a per-event UUID and ISO 8601 timestamp so Zapier-side steps can deduplicate. 1. **Create the Catch Hook.** In Zapier, add a Zap, choose _Webhooks by Zapier → Catch Hook_, and copy the generated URL. 2. **Add a webhook in WordPress.** In your webhook tool, create a webhook, paste the URL, and select a trigger such as `woocommerce_order_status_completed` or a Contact Form 7 submission. 3. **Map the payload.** Rename and restructure fields so the JSON matches what your Zap steps read. 4. **Test and turn on.** Send a test delivery, confirm Zapier caught it, then enable the webhook. / Reliability ## Why not just use Zapier's built-in WordPress app? Zapier's native WordPress and WooCommerce apps poll your REST API and cover a fixed list of triggers (new post, new order). A webhook covers _any_ `do_action` on your site, fires instantly instead of on Zapier's polling interval, and — with a queue behind it — survives transient failures. It also changes the cost model: you are sending one HTTP request per event from your own server rather than leaning on a polling connector. | Aspect | Zapier WordPress app (poll) | Raw `wp_remote_post` | Webhook Actions | | --- | --- | --- | --- | | Latency | Up to the polling interval | Instant on the event | Instant on the event | | Trigger coverage | Fixed app trigger list | Any hook you wire up by hand | Any `do_action`, chosen in wp-admin | | Delivery | Zapier pulls when it polls | Fire-and-forget — no retry | Queued, retried with backoff | | Dedup | App-dependent | Roll your own | Per-event UUID + timestamp | | Setup | Connect the Zapier app | Write & maintain code | No code — map fields in the UI | | Hosting | Relies on Zapier connector | Your server | Your server | / Alternatives ## What if you want to avoid Zapier's task fees entirely? The same push pattern targets any automation platform. Point the webhook at an [n8n webhook node](https://wpwebhooks.org/blog/gravity-forms-to-n8n-webhook/) or a Make scenario instead of Zapier's Catch Hook and you get comparable routing without per-task pricing — n8n in particular can be self-hosted. If you are weighing tools, the [webhook plugin comparison](https://wpwebhooks.org/blog/wp-webhooks-alternative/) lays out the trade-offs honestly. The connection technique never changes: WordPress event → HTTP POST → whatever catches it. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress to Zapier Webhook: Trigger Zaps from Any Hook","description":"Connect WordPress to Zapier: send any post, form, or WooCommerce event to a Zap via webhook — Catch Hook setup, payload format, and a self-hosted alternative.","datePublished":"2026-07-07","dateModified":"2026-07-07","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-zapier-webhook/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-zapier-webhook/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of a neon WordPress logo linked by a glowing \"WEBHOOK\" node to a Zapier panel over a night city, with WordPress event labels User Login, Post Published, Form Submitted, and New Order."},"keywords":["zapier wordpress","zapier wordpress plugin","wordpress zapier webhook","connect wordpress to zapier","wordpress zapier integration","send wordpress events to zapier"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress to Zapier Webhook: Trigger Zaps from Any Hook","item":"https://wpwebhooks.org/blog/wordpress-zapier-webhook/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do I connect WordPress to Zapier with a webhook?","acceptedAnswer":{"@type":"Answer","text":"Create a Zap with the \"Webhooks by Zapier\" trigger and choose Catch Hook to get a unique URL. Then send your WordPress or WooCommerce events to that URL as an HTTP POST with a JSON body. Zapier parses the JSON and treats each incoming POST as a trigger event you can act on in later Zap steps."}},{"@type":"Question","name":"What is a Zapier Catch Hook URL?","acceptedAnswer":{"@type":"Answer","text":"It is a unique endpoint Zapier generates when you add a Webhooks by Zapier trigger with the Catch Hook event, in the form https://hooks.zapier.com/hooks/catch/123456/abcde/. Anything POSTed to it becomes a trigger event. The URL itself is the secret, so treat it like a credential."}},{"@type":"Question","name":"Do I need to write code to send WordPress events to Zapier?","acceptedAnswer":{"@type":"Answer","text":"No. A no-code webhook tool lets you paste the Catch Hook URL as the endpoint, pick a WordPress trigger, map the payload fields, and send a test delivery — all from the admin UI. Any tool that posts to an arbitrary HTTP endpoint reaches Zapier, and a good one queues each delivery with retry."}},{"@type":"Question","name":"Why use a webhook instead of Zapier's built-in WordPress app?","acceptedAnswer":{"@type":"Answer","text":"Zapier's native WordPress app polls your REST API on a schedule and only supports a fixed list of triggers. A webhook fires instantly for any do_action on your site, covers events the app cannot see, and — with a queue behind it — retries transient failures instead of losing the event."}},{"@type":"Question","name":"Can I use the same setup for n8n or Make instead of Zapier?","acceptedAnswer":{"@type":"Answer","text":"Yes. The push pattern is identical — point the webhook at an n8n webhook node or a Make scenario URL instead of a Zapier Catch Hook. n8n can be self-hosted, which avoids per-task pricing. The WordPress side does not change: an event fires an HTTP POST that the target platform catches."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-zapier-webhook.png","caption":"FIG 01 — WordPress event to Zapier Catch Hook","description":"A WordPress or WooCommerce do_action is bound to a webhook. The webhook maps the payload, queues the delivery, and POSTs JSON to the Catch Hook URL from Webhooks by Zapier. Zapier receives the payload as a trigger and runs the Zap steps. A non-2xx response is retried with exponential backoff.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "as_schedule_recurring_action: Signature & Interval" description: "as_schedule_recurring_action documentation: PHP signature, first-run timestamp, interval seconds, group and $unique parameters, with recurring code examples." url: "https://wpwebhooks.org/blog/as-schedule-recurring-action-reference/" date: "2026-07-04" --- # as_schedule_recurring_action: Signature & Interval **TL;DR:** `as_schedule_recurring_action()` runs a hook repeatedly at a fixed interval in seconds, re-enqueuing the next occurrence only after the current one finishes. - Signature: `as_schedule_recurring_action( int $timestamp, int $interval_in_seconds, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 )` - `$timestamp` is the first run; each later run is scheduled at `previous_run + $interval`, so missed runs do not stack. - Guard registration with `as_has_scheduled_action()` or `$unique = true` so activation does not create a second recurring series. / Signature ## What is the **as\_schedule\_recurring\_action** signature? [`as_schedule_recurring_action()`](https://actionscheduler.org/api/#function-reference--as_schedule_recurring_action) schedules a background action to fire again and again at a fixed number of seconds apart. It is part of [Action Scheduler](https://actionscheduler.org/), the queue library bundled with WooCommerce. Like every scheduling function, it maps to a WordPress hook: each run calls `do_action( $hook, ...$args )`, so the executing code is an ordinary [`add_action()`](https://developer.wordpress.org/reference/functions/add_action/) callback. PHP — as\_schedule\_recurring\_action full signature ``` as_schedule_recurring_action( int $timestamp, // Unix time of the first run int $interval_in_seconds, // gap between runs, e.g. 3600 = hourly string $hook, // action hook fired via do_action() array $args = [], // args spread into the callback string $group = '', // label for filtering / bulk cancel bool $unique = false, // skip if an identical series is pending int $priority = 10 // 0-255, lower runs first (AS 3.5+) ): int; // returns the first action's ID, or 0 on failure ``` It mirrors [`as_schedule_single_action()`](https://wpwebhooks.org/blog/action-scheduler-api-functions/) exactly, with one extra second argument: `$interval_in_seconds`. Everything after it — `$hook`, `$args`, `$group`, `$unique`, `$priority` — behaves identically. / Interval ## How does the **$interval\_in\_seconds** parameter work? The interval is a plain integer count of seconds — there are no named schedules like WP-Cron's `hourly` or `daily`. Compute it directly: `HOUR_IN_SECONDS` (3600), `DAY_IN_SECONDS` (86400), or your own value. Crucially, the interval is measured from when each run _completes_, not from a fixed wall-clock grid. After an occurrence finishes, Action Scheduler enqueues the next one at that run's scheduled time plus the interval, so a job set to run "every hour" drifts slightly if runs are delayed. When you need a job pinned to the clock (always at the top of the hour, always at 06:00), use `as_schedule_cron_action()` with a cron expression instead. | Goal | Interval value | WordPress constant | | --- | --- | --- | | Every 5 minutes | 300 | `5 * MINUTE_IN_SECONDS` | | Hourly | 3600 | `HOUR_IN_SECONDS` | | Every 6 hours | 21600 | `6 * HOUR_IN_SECONDS` | | Daily | 86400 | `DAY_IN_SECONDS` | | Weekly | 604800 | `WEEK_IN_SECONDS` | FIG 01 — Fixed-interval re-enqueue loop / Missed runs ## What happens to **missed** recurring runs? They do not pile up. Because the next occurrence is only scheduled after the previous one finishes, a site that goes hours without triggering the queue runs the action once when the queue next fires, then schedules the following occurrence from there. This is the opposite of WP-Cron's [`wp_schedule_event()`](https://developer.wordpress.org/reference/functions/wp_schedule_event/), where a backlog of overdue events can all fire in a burst on the next page load. The trade-off is drift: if runs are consistently late, "hourly" becomes "every hour plus the lateness". For most maintenance work that is exactly what you want. / Registration ## How do you register a recurring action **without duplicates**? The classic bug is calling `as_schedule_recurring_action()` on every plugin load, or on every activation, which creates a second, third, and fourth parallel series — all firing the same hook. Guard the call so it only schedules when nothing is already queued. The two safe patterns are an `as_has_scheduled_action()` check or the `$unique = true` flag. PHP — register once, on init or activation ``` add_action( 'init', function() { // Only schedule if this series is not already queued if ( as_has_scheduled_action( 'myplugin_hourly_sync', [], 'sync' ) ) { return; } as_schedule_recurring_action( time(), HOUR_IN_SECONDS, 'myplugin_hourly_sync', [], 'sync' ); } ); add_action( 'myplugin_hourly_sync', function() { // recurring background work runs here } ); ``` > Recurring actions schedule the next run after the current one finishes — so a stalled site never wakes up to a stampede of overdue jobs. — Action Scheduler admin guide / vs recurring WP-Cron ## How does it compare to a recurring **WP-Cron** event? Both repeat a hook on a schedule, but Action Scheduler adds persistence, per-run logging, and retry — and it will not fire a backlog all at once. | Feature | wp\_schedule\_event (recurring) | as\_schedule\_recurring\_action | | --- | --- | --- | | Schedule unit | Named schedule (hourly, daily…) | Interval in seconds | | Missed runs | Can fire in a burst on next load | One run, then reschedule forward | | Retry | None | Automatic on failure | | Per-run log | None | Full attempt history in wp-admin | | Cancel one series | wp\_clear\_scheduled\_hook (all) | as\_unschedule\_all\_actions by hook/group | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Cancel ## How do you stop a recurring action? Call `as_unschedule_all_actions( $hook, $args, $group )` to cancel the whole series, typically on plugin deactivation. It is safe to call even when nothing is scheduled — it is a no-op rather than an error. To inspect before acting, `as_next_scheduled_action( $hook )` returns the next run's Unix timestamp, or `false` if the series has stopped. The full query-and-cancel set is covered in the [Action Scheduler PHP API reference](https://wpwebhooks.org/blog/action-scheduler-api-functions/). / Webhooks ## Where does this fit with **webhook delivery**? Recurring actions are for _polling and maintenance_ — syncing a remote list every hour, pruning logs nightly — not for event-driven webhooks. When you want an outbound call the moment something happens in WordPress, you want an event trigger, not a timer: hook `do_action` and POST on the event itself, behind a queue that retries and logs each delivery. Use `as_schedule_recurring_action()` when the trigger is genuinely time-based, and an event-driven webhook when it is not. For the whole scheduling family, see the [API functions reference](https://wpwebhooks.org/blog/action-scheduler-api-functions/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"as_schedule_recurring_action: Signature & Interval","description":"as_schedule_recurring_action documentation: PHP signature, first-run timestamp, interval seconds, group and $unique parameters, with recurring code examples.","datePublished":"2026-07-04","dateModified":"2026-07-04","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/as-schedule-recurring-action-reference/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["as schedule recurring action","as schedule recurring action signature","as schedule recurring action interval","action scheduler recurring action","as schedule recurring action parameters","action scheduler recurring interval seconds"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"as_schedule_recurring_action: Signature & Interval","item":"https://wpwebhooks.org/blog/as-schedule-recurring-action-reference/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is the signature of as_schedule_recurring_action?","acceptedAnswer":{"@type":"Answer","text":"as_schedule_recurring_action( int $timestamp, int $interval_in_seconds, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ) and it returns the first action's integer ID. It matches as_schedule_single_action exactly but adds $interval_in_seconds as the second argument."}},{"@type":"Question","name":"How does the interval work in as_schedule_recurring_action?","acceptedAnswer":{"@type":"Answer","text":"The interval is a plain integer count of seconds — for example 3600 for hourly — with no named schedules. The next occurrence is scheduled at the previous run plus the interval, measured from when each run completes rather than from a fixed clock grid, so runs can drift slightly if they are delayed."}},{"@type":"Question","name":"What happens to missed recurring runs?","acceptedAnswer":{"@type":"Answer","text":"They do not stack up. Because the next occurrence is only scheduled after the current one finishes, a site that goes idle runs the action once when the queue next fires, then schedules the following run from there. This is unlike recurring WP-Cron, where a backlog of overdue events can fire in a burst."}},{"@type":"Question","name":"How do I register a recurring action without creating duplicates?","acceptedAnswer":{"@type":"Answer","text":"Guard the call so it only schedules when nothing is already queued. Either check if ( ! as_has_scheduled_action( $hook, $args, $group ) ) before scheduling, or pass $unique = true. Both prevent activation or repeated init hooks from creating multiple parallel series firing the same hook."}},{"@type":"Question","name":"How do I stop a recurring action?","acceptedAnswer":{"@type":"Answer","text":"Call as_unschedule_all_actions( $hook, $args, $group ) to cancel the whole series, typically on plugin deactivation. It is safe to call even when nothing is scheduled — it is a no-op rather than an error. Use as_next_scheduled_action( $hook ) to check the next run timestamp before cancelling."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/as-schedule-recurring-action.png","caption":"FIG 01 — Fixed-interval re-enqueue loop","description":"as_schedule_recurring_action runs the hook at the first timestamp, then after each execution completes it schedules the next occurrence at the previous run plus the interval in seconds. Because the next slot is only booked after the current run finishes, missed runs do not stack up the way overdue WP-Cron events can.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Abilities API: Expose Plugin Features to AI" description: "What the WordPress Abilities API is, how wp_register_ability() works, and how to expose plugin features to AI agents and MCP clients safely." url: "https://wpwebhooks.org/blog/wordpress-abilities-api-ai-agents/" date: "2026-07-02" --- # WordPress Abilities API: Expose Plugin Features to AI **TL;DR:** The WordPress Abilities API is a central registry where plugins describe what they can do — a label, a JSON input schema, a permission callback, and an execute callback — under a namespaced id like `my-plugin/create_webhook`. - Register once, consume three ways: your own PHP, the REST surface under `/wp-abilities/v1/`, and MCP clients like Claude Code or Cursor via the MCP Adapter. - Hook `wp_abilities_api_init` and guard with `function_exists('wp_register_ability')` so the plugin still works on older WordPress. - Every invocation passes the ability's permission callback before its execute callback runs — AI access inherits your capability model. - Webhook Actions 2.0 publishes 17 webhook-building abilities this way; its in-admin Build with AI agent calls the same registry directly. / Overview ## What is the **WordPress Abilities API**? The Abilities API is WordPress core's standard way for plugins, themes, and core itself to describe distinct machine-callable capabilities in one central registry. An _ability_ is a typed operation — "create a webhook", "list delivery logs" — bundled with a human-readable label and description, a JSON Schema for its input and output, an execute callback, and a permission callback. The canonical reference lives in the [Abilities API handbook](https://developer.wordpress.org/apis/abilities-api/), and the API's development happens in the [WordPress/abilities-api repository](https://github.com/WordPress/abilities-api). The motivation is AI. Agents are only as good as the operations they can call, and until now every plugin invented its own bridge: bespoke REST endpoints, admin-ajax handlers, prompt glue. The Abilities API replaces that with a discoverable catalog — an AI client can enumerate what a site can do, read each ability's schema, and invoke it with validated input, without knowing anything about the plugin's internals¹. / Registration ## How does **wp\_register\_ability()** work? You register each ability on the `wp_abilities_api_init` action with a namespaced name — `vendor/ability` — and a definition array. The function reference is on [developer.wordpress.org](https://developer.wordpress.org/reference/functions/wp_register_ability/); registration returns a [`WP_Ability`](https://developer.wordpress.org/reference/classes/wp_ability/) instance. Group related abilities with `wp_register_ability_category()` so clients can present them as one toolset. PHP — registering an ability (guarded for older WordPress) ``` // The Abilities API ships in newer WordPress cores — guard so the // plugin still activates cleanly where it is absent. if ( function_exists( 'wp_register_ability' ) ) { add_action( 'wp_abilities_api_init', function () { wp_register_ability_category( 'webhook-actions', [ 'label' => __( 'Webhook Actions', 'my-plugin' ), ] ); wp_register_ability( 'my-plugin/create_webhook', [ 'label' => 'Create webhook', 'description' => 'Create a new outbound webhook (created disabled).', 'category' => 'webhook-actions', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'name' => [ 'type' => 'string' ], 'endpoint_url' => [ 'type' => 'string' ], ], 'required' => [ 'name', 'endpoint_url' ], ], 'output_schema' => [ 'type' => 'object' ], 'execute_callback' => fn( $input ) => $registry->execute( 'create_webhook', $input ), 'permission_callback' => fn() => current_user_can( 'manage_options' ), ] ); } ); } ``` / Schemas ## How do **input schemas** make abilities machine-callable? The input schema is what turns a PHP callback into something a language model can call reliably. Models hallucinate parameter names and shapes; a JSON Schema pins the contract — property names, types, required fields, and enums — so the client can construct valid input and the site can validate it before the callback ever runs. Enums are the underrated part. A property like `conditions_evaluate_on` declared as `enum: ['original', 'transformed']` means the model cannot invent a third mode — the invalid call is rejected at the schema boundary, not deep inside your business logic. The same schema doubles as documentation: agents read the `description` strings to decide _which_ ability fits the user's goal, so write them like one-line docs, not labels. / Permissions ## How do **permission callbacks** gate AI access? Every invocation — from PHP, REST, or an MCP client — passes the ability's `permission_callback` before the `execute_callback` runs. There is no separate "AI permission system" to design: abilities inherit WordPress's capability model, and an agent can never do more than the identity it authenticates as. In practice you want more granularity than one capability check. The [Webhook Actions plugin](https://wordpress.org/plugins/flowsystems-webhook-actions/) (as of v2.0) tags each ability with a scope — `read` for list/inspect operations, `full` for mutations — mirroring its REST API token scopes, and exposes the decision through a filter so site owners can open specific scopes to token-mediated MCP access without touching code. Destructive abilities additionally carry a `requires_confirm` flag in their meta: enabling or deleting a webhook always demands explicit user confirmation, and an HTTP probe demands it only when the probe method is unsafe. FIG 01 — One ability registration, three consumers > Register an ability once and every consumer — your own admin UI, the REST surface, and any MCP client — gets the same typed, permission-gated operation. — the registration pattern / Discovery ## How do **external AI clients** discover abilities? Two surfaces come free with registration. The REST surface under `/wp-abilities/v1/` lets any authenticated HTTP client list registered abilities, read their schemas, and run them. And the [MCP Adapter](https://github.com/WordPress/mcp-adapter) exposes the same registry over the [Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) — the open standard AI tools use to connect to external systems — so Claude Code, Cursor, and other MCP clients can discover and invoke your site's toolset like any other MCP server². This is the payoff of central registration: you write zero per-client integration code. The day a new MCP-speaking tool appears, it can already drive your plugin. ![Cyberpunk night-city street scene: a large glowing WordPress logo mounted on a circuit-board chip at the left, green and cyan circuit traces streaming rightward into a luminous wireframe human brain, beneath the neon headline “WordPress Abilities API: Expose Plugin Features to AI” and the subtitle “Integrating AI with WP plugins — future of WordPress development”.](https://wpwebhooks.org/blog/wordpress-abilities-api-ai-agents/og_image.jpg) / Case study ## How does a **real plugin** register its toolset? Webhook Actions 2.0 publishes 17 abilities under the `flowsystems-webhook-actions/*` namespace: five read abilities (list triggers, list webhooks, inspect a webhook, read a captured trigger schema, read delivery logs) and twelve full-scope abilities (list credential names, create and update webhooks, set field mappings and conditions, assign credentials, run test dispatches, probe endpoints, build chains, enable and delete webhooks). The instructive part is that the registration is _purely additive_. The plugin's own in-admin AI agent calls the internal registry directly in PHP, so Build with AI works on any supported WordPress version. When the Abilities API is present, a thin registrar class also publishes each definition with `wp_register_ability()` — same schemas, same callbacks, same permission gates. One toolset, defined once, consumed by the in-admin agent, the REST surface, and external MCP clients alike. (For how the agent side works, see [the plan-first agent architecture](https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/).) | Concern | DIY AI bridge (custom endpoints + glue) | Abilities API (Webhook Actions) | | --- | --- | --- | | Discovery | Hand-written docs the model never sees | Machine-readable registry with JSON Schemas | | Validation | Each endpoint re-implements input checks | Schema-validated input before the callback runs | | Permissions | Per-endpoint checks you must remember | Central permission callback + confirm meta per ability | | New AI clients | One custom integration per tool | REST + MCP Adapter serve every client | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Adoption ## Should your plugin **adopt the Abilities API** now? If your plugin has operations an agent could usefully drive — creating content, configuring integrations, querying state — yes, and the additive pattern makes it low-risk. Keep your internal API as the source of truth, register abilities as a thin layer over it, guard with `function_exists()`, and mark destructive operations with confirmation metadata. Your plugin loses nothing on older WordPress and gains a full AI surface on newer cores. Two design rules from building this: keep ability granularity at the level of user intent (one ability per meaningful operation, not per database row), and never expose secrets through read abilities — list credential _names_, never values. If you already run a REST API, your ability scopes should mirror its token scopes so there is exactly one permission story to audit. For the REST-first view of the same toolset, see [creating and managing webhooks over the REST API](https://wpwebhooks.org/blog/create-manage-wordpress-webhooks-rest-api/). /Footnotes ¹ Abilities API handbook and background: [developer.wordpress.org/apis/abilities-api/](https://developer.wordpress.org/apis/abilities-api/); development at [github.com/WordPress/abilities-api](https://github.com/WordPress/abilities-api). ² The Model Context Protocol was introduced by Anthropic as an open standard for connecting AI assistants to external systems: [anthropic.com/news/model-context-protocol](https://www.anthropic.com/news/model-context-protocol). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Abilities API: Expose Plugin Features to AI","description":"What the WordPress Abilities API is, how wp_register_ability() works, and how to expose plugin features to AI agents and MCP clients safely.","datePublished":"2026-07-02","dateModified":"2026-07-02","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-abilities-api-ai-agents/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-abilities-api-ai-agents/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk night-city street scene: a large glowing WordPress logo mounted on a circuit-board chip at the left, green and cyan circuit traces streaming rightward into a luminous wireframe human brain, beneath the neon headline “WordPress Abilities API: Expose Plugin Features to AI” and the subtitle “Integrating AI with WP plugins — future of WordPress development”."},"keywords":["wordpress abilities api","wp register ability","wordpress ai agents","wordpress mcp adapter","expose plugin features to ai","abilities api rest"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Abilities API: Expose Plugin Features to AI","item":"https://wpwebhooks.org/blog/wordpress-abilities-api-ai-agents/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is the WordPress Abilities API?","acceptedAnswer":{"@type":"Answer","text":"It is a central WordPress registry where plugins, themes, and core describe machine-callable capabilities. Each ability bundles a namespaced id, a label and description, JSON input and output schemas, an execute callback, and a permission callback, so AI agents and other clients can discover and invoke plugin features in a standard, validated way."}},{"@type":"Question","name":"How do I register an ability in WordPress?","acceptedAnswer":{"@type":"Answer","text":"Hook the wp_abilities_api_init action and call wp_register_ability() with a namespaced name like my-plugin/create_webhook and a definition array: label, description, category, input_schema, output_schema, execute_callback, and permission_callback. Guard the registration with function_exists() so the plugin still works on WordPress versions without the API."}},{"@type":"Question","name":"How do AI tools like Claude Code use WordPress abilities?","acceptedAnswer":{"@type":"Answer","text":"Through two surfaces that come free with registration: the REST surface under /wp-abilities/v1/, and the MCP Adapter, which exposes the registry as a Model Context Protocol server. Any MCP client — Claude Code, Cursor, and others — can then list the site's abilities, read their schemas, and invoke them with validated input."}},{"@type":"Question","name":"Is the Abilities API safe to expose on a production site?","acceptedAnswer":{"@type":"Answer","text":"Yes, if you use its gates. Every invocation passes the ability's permission callback before the execute callback runs, so access inherits your capability model. Mark destructive abilities with confirmation metadata, scope read and write abilities separately, and never expose secret values through read abilities."}},{"@type":"Question","name":"Does my plugin need the Abilities API to work with AI?","acceptedAnswer":{"@type":"Answer","text":"No — and the best pattern treats it as additive. Keep an internal registry your own features call directly, then also publish each definition via wp_register_ability() when the API is present. The Webhook Actions plugin is built exactly this way: its in-admin Build with AI agent works on any supported WordPress, and MCP/REST access lights up automatically on newer cores."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/abilities-api-flow.png","caption":"FIG 01 — One ability registration, three consumers","description":"A plugin registers each operation once with wp_register_ability under its own namespace. The Abilities API registry then exposes the same ability to three consumers: the plugin's own in-admin agent calling it directly in PHP, the REST surface under /wp-abilities/v1/, and the MCP Adapter that external AI clients such as Claude Code or Cursor connect to. Every path passes the ability's permission callback before its execute callback runs.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress AI Client: wp_ai_client_prompt() Explained" description: "How the WordPress 7.0 AI Client works: provider Connectors, wp_ai_client_prompt(), model fallback chains, and when you still need your own API key." url: "https://wpwebhooks.org/blog/wordpress-ai-client-wp-ai-client-prompt/" date: "2026-06-29" --- # WordPress AI Client: wp_ai_client_prompt() Explained **TL;DR:** WordPress 7.0 ships an AI Client: a provider-agnostic PHP SDK with a fluent entry point, `wp_ai_client_prompt()`, over centrally configured providers (Settings → Connectors). - WordPress stores the provider credentials — your plugin never handles or saves an API key on this path. - Pin a model with `using_model_preference()`, and on a retryable 429 climb a fallback ladder — a sibling model first, then _across providers_. - There is no native tool calling — agents need a structured-output protocol layered on top. - Detect availability with `function_exists()` + `wp_supports_ai()`, and keep a bring-your-own-key path for sites that have no connector configured. / Overview ## What is the **WordPress AI Client**? The AI Client is WordPress core's provider-agnostic layer for talking to large language models. It grew out of the [WordPress/php-ai-client](https://github.com/WordPress/php-ai-client) SDK developed by the [WordPress AI team](https://make.wordpress.org/ai/), and lands in WordPress 7.0 with a global entry point: [`wp_ai_client_prompt()`](https://developer.wordpress.org/reference/functions/wp_ai_client_prompt/). One API, multiple providers — Anthropic, OpenAI, Google — selected by configuration rather than code. For plugin developers this ends a real problem: every AI-flavoured plugin shipping its own SDK, its own settings screen, and its own copy of the user's API keys. With the AI Client there is one place keys live, one prompt-building API, and one registry your plugin can introspect. / Connectors ## How do **provider Connectors** work? Site owners configure AI providers once, at the WordPress level (Settings → Connectors). Every plugin using the AI Client shares those connections — which means your plugin stores _no_ keys, renders _no_ key field, and inherits credential rotation for free. From code, the client exposes a registry you can introspect: which providers are configured, and which models each one offers, complete with capability metadata. Two practical notes from production use. First, model lists are fetched live from each provider's list-models endpoint, so cache them (a transient with a short TTL is enough) rather than hitting the network on every admin page load. Second, filter models by capability: a text-generation agent should skip models that advertise image, audio, video, or embedding output — the registry metadata tells you which is which. / The API ## How does **wp\_ai\_client\_prompt()** work? It returns a fluent builder: you chain configuration and finish with a generation call. PHP — a pinned-model text generation ``` $builder = wp_ai_client_prompt( $prompt ) ->using_system_instruction( $system ) ->using_temperature( 0.2 ) // A single-entry preference pins this exact model (and its provider). ->using_model_preference( 'claude-sonnet-4-6' ); $text = $builder->generate_text(); if ( is_wp_error( $text ) ) { // Provider failure, quota, or misconfiguration — decide whether to retry. } ``` One gotcha will bite anyone writing defensive code: the builder dispatches its fluent methods through `__call()`, so they are invisible to `method_exists()`. Do not feature-detect individual builder methods — call them inside a `try/catch` and let the builder surface failures from `generate_text()` as a `WP_Error`. ![Rain-slick cyberpunk city street at night dominated by a giant neon-green holographic circuit-board panel. The panel shows the WordPress logo beside the glowing headline “WP AI CLIENT” and the function name wp_ai_client_prompt(), framed by scrolling PHP code, audio-waveform bars and an AI-chat speech bubble. Neon billboards on the surrounding skyscrapers read AI HUB, CYBER_NET, WP_NOC and WP_AI_CLIENT.](https://wpwebhooks.org/blog/wordpress-ai-client-wp-ai-client-prompt/og_image.jpg) / Availability ## How do you **detect the AI Client** safely? Three checks, all deterministic and free — none makes an API call. First, `function_exists('wp_ai_client_prompt')` confirms the client is present (older WordPress). Second, [`wp_supports_ai()`](https://developer.wordpress.org/reference/functions/wp_supports_ai/) confirms the site hasn't opted out of AI features — respect it. Third, ask the builder itself whether text generation is currently possible, i.e. whether any provider is actually configured: PHP — capability check before offering AI features ``` public static function isAvailable(): bool { if ( ! function_exists( 'wp_ai_client_prompt' ) ) { return false; } if ( function_exists( 'wp_supports_ai' ) && ! wp_supports_ai() ) { return false; } try { return wp_ai_client_prompt( 'ping' ) ->is_supported_for_text_generation() === true; } catch ( \Throwable $e ) { return false; } } ``` / Resilience ## How do you build a **fallback chain**? Single-provider setups fail in boring, predictable ways: too many requests hit the provider and every call 429s until the window resets. The fix is a candidate chain — the user's preferred model first, then _a sibling model on the same provider, then one representative model from each other configured provider_. On a retryable error (rate limit, quota, transient 5xx) you try the next candidate; on a non-retryable error (bad key, malformed request) you stop immediately, because another provider will not fix your bug. The order matters, because a 429 has two very different causes. A _per-model rate limit_ is often scoped to a single model, so the cheapest fix is to hop to a sibling model on the same provider — no new credentials, no extra latency. A _per-project quota_, by contrast, is shared across all of one provider's models, so once you have exhausted it the only thing that helps is jumping to a different provider. Climb the ladder in cost order: sibling model first, then cross-provider. FIG 01 — Model fallback ladder > A 429 is either a per-model rate limit or a per-project quota. Try a sibling model first; cross providers only when the whole provider is exhausted. — fallback design rule / Limits ## What are the **AI Client's limits**? Two matter for anything agent-shaped. There is **no native tool calling**: the client generates text, and if you want the model to propose operations, you must layer a structured-output protocol on top — ask for a strict JSON envelope and parse it defensively. And the prompt builder takes **a single user prompt**, not a message array, so multi-turn conversations must be folded into one transcript string with the system instruction passed separately via `using_system_instruction()`. Neither is a blocker — the [plan-first agent architecture](https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/) shows a production pattern for both — but they shape your design: the protocol lives in your prompts and parsers, not in the transport. / BYOK ## When do you still need **bring-your-own-key**? Whenever the AI Client cannot serve: the site runs an older WordPress, no connector is configured, or the owner simply prefers a dedicated key for your plugin. A robust integration treats the transport as swappable. The [Webhook Actions plugin](https://wordpress.org/plugins/flowsystems-webhook-actions/) (in its upcoming Build with AI release) resolves it with a three-state source option — _auto_ (prefer the AI Client when available, else BYO), or pinned to either path. On the BYO path, per-provider keys are stored encrypted in its write-only Credentials Vault, models are discovered from each provider's own models endpoint, and the same cross-provider fallback applies across the configured BYO providers. The result is one AI feature with two credential sources presented identically — and zero keys handled by the plugin whenever WordPress can hold them instead. | Concern | Hardcoding one provider SDK | AI Client + BYOK fallback (Webhook Actions) | | --- | --- | --- | | API keys | Your settings page stores another copy of the key | WordPress Connectors hold them; BYO keys encrypted in a write-only vault | | Provider outage / quota | Feature is down until the quota resets | Retryable failures hop to the next configured provider | | Older WordPress | Works, but keys sprawl per plugin | Auto mode falls back to the BYO path seamlessly | | Model choice | Hardcoded model id in the codebase | Live model catalog per provider, user-selectable, cached | → The [Webhook Actions plugin](https://wpwebhooks.org/wordpress-webhook-plugin/) uses this exact stack for its upcoming Build with AI feature — WordPress 7.0 AI Client when configured, encrypted BYO keys when not — so describing an integration in plain language builds real, queued, retried webhooks. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) /Footnotes ¹ AI Client entry point: [wp\_ai\_client\_prompt() reference](https://developer.wordpress.org/reference/functions/wp_ai_client_prompt/); SDK at [github.com/WordPress/php-ai-client](https://github.com/WordPress/php-ai-client). ² WordPress AI team roadmap and announcements: [make.wordpress.org/ai](https://make.wordpress.org/ai/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress AI Client: wp_ai_client_prompt() Explained","description":"How the WordPress 7.0 AI Client works: provider Connectors, wp_ai_client_prompt(), model fallback chains, and when you still need your own API key.","datePublished":"2026-06-29","dateModified":"2026-06-29","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-ai-client-wp-ai-client-prompt/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-ai-client-wp-ai-client-prompt/og_image.jpg","width":1200,"height":630,"caption":"Rain-slick cyberpunk city street at night dominated by a giant neon-green holographic circuit-board panel. The panel shows the WordPress logo beside the glowing headline “WP AI CLIENT” and the function name wp_ai_client_prompt(), framed by scrolling PHP code, audio-waveform bars and an AI-chat speech bubble. Neon billboards on the surrounding skyscrapers read AI HUB, CYBER_NET, WP_NOC and WP_AI_CLIENT."},"keywords":["wordpress ai client","wp ai client prompt","wordpress 7 ai","wordpress ai provider connectors","wordpress llm integration","php ai client"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress AI Client: wp_ai_client_prompt() Explained","item":"https://wpwebhooks.org/blog/wordpress-ai-client-wp-ai-client-prompt/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is wp_ai_client_prompt() in WordPress?","acceptedAnswer":{"@type":"Answer","text":"It is the entry point of the WordPress 7.0 AI Client — a provider-agnostic PHP layer for calling large language models. It returns a fluent builder: chain using_system_instruction(), using_temperature(), and using_model_preference(), then call generate_text(), which returns the generated string or a WP_Error."}},{"@type":"Question","name":"Where does WordPress store AI provider API keys?","acceptedAnswer":{"@type":"Answer","text":"At the WordPress level, in the provider Connectors settings — not in individual plugins. Every plugin using the AI Client shares those centrally managed connections, so a well-behaved plugin stores no keys and renders no API-key field of its own on this path."}},{"@type":"Question","name":"How do I check if the WordPress AI Client is available?","acceptedAnswer":{"@type":"Answer","text":"Three deterministic checks that make no API call: function_exists('wp_ai_client_prompt') for presence, wp_supports_ai() to respect sites that opted out of AI features, and the builder's is_supported_for_text_generation() to confirm a provider is actually configured. Wrap the last one in try/catch."}},{"@type":"Question","name":"Does the WordPress AI Client support tool calling?","acceptedAnswer":{"@type":"Answer","text":"No. The AI Client generates text; it has no native tool-calling or function-calling. Agent-style plugins layer a structured-output protocol on top — instructing the model to return a strict JSON envelope describing proposed operations, then parsing and executing those locally."}},{"@type":"Question","name":"What happens when an AI provider hits its rate limit or quota?","acceptedAnswer":{"@type":"Answer","text":"The request fails with a retryable error such as a 429 (Too Many Requests). A resilient integration keeps a candidate chain and climbs it in cost order: the preferred model first, then a sibling model on the same provider (a per-model rate limit is often scoped to one model), then one model from each other configured provider (a per-project quota is shared across all of a provider's models, so only crossing providers helps). Non-retryable errors like a bad key stop the chain immediately."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wp-ai-client-fallback.png","caption":"FIG 01 — Model fallback ladder","description":"A prompt is first pinned to the user's preferred model on provider A. On a retryable 429 the chain climbs in cost order: first a sibling model on the same provider (a per-model rate limit is often scoped to one model), then a model on provider B, then provider C (a per-project quota is shared across one provider's models, so only crossing providers helps). Auth or bad-request errors stop the chain immediately. The first successful model returns text.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Build an AI Agent in a WordPress Plugin: Architecture" description: "Inside an in-admin AI agent for WordPress: plan-first JSON envelopes, typed ability steps, confirmation gates, undo stacks, and trace logging." url: "https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/" date: "2026-06-26" --- # Build an AI Agent in a WordPress Plugin: Architecture **TL;DR:** An in-admin AI agent can build WordPress integrations safely if it works _plan-first_: the model proposes an ordered plan of typed steps as JSON, and the plugin executes them locally under your permission model. - A strict JSON envelope (message + clarifying questions + plan) replaces native tool calling — so the loop works over any transport, including the WordPress 7.0 AI Client. - Safety is structural: new webhooks are created disabled, going live or deleting requires explicit confirmation, and every mutation snapshots prior state onto an undo stack. - Steps execute one at a time; a failure halts the run without touching later steps. - A dev trace (model, latency, full prompt, parse status) is the only way to debug an agent you cannot see. / Why ## Why put an **AI agent inside wp-admin**? Because the agent can touch what an external chatbot cannot: the site's real state. An in-admin agent building a webhook integration reads the actual trigger catalog, inspects captured payloads from hooks that already fired, sees the webhooks that already exist, and probes destination endpoints from the server — so it works from real data instead of guesses. This is the design behind _Build with AI_, the agent coming in the next major release of the [Webhook Actions plugin](https://wordpress.org/plugins/flowsystems-webhook-actions/): describe an integration in plain language, and the agent proposes, builds, and tests the webhooks, field mappings, conditions, and chains. The catch is obvious: a language model with write access to a production site is a liability unless the architecture makes unsafe actions structurally hard. That is what plan-first execution is for. / The loop ## What is a **plan-first agent loop**? A loop where the model never mutates anything during the conversation. Each turn, the model receives the transcript plus a system prompt describing the available typed operations, and replies with a proposal: a short assistant message, optional clarifying questions, and an optional ordered plan of steps. The plugin normalizes the plan, shows it to the user for review and editing, and only then executes it — step by step, locally, through its own code paths. The system prompt makes the contract explicit: the agent must never claim to have changed anything itself; it proposes, the plugin disposes. It is also told to prefer action over interrogation — when the goal is clear, propose the full plan with sensible defaults, leave genuinely unknown fields blank, and ask only for those. FIG 01 — The plan-first agent loop / Protocol ## How does a **JSON envelope** replace tool calling? The model is instructed to reply with a single JSON object and nothing else. That envelope is the whole protocol — which means the loop runs identically over the Anthropic, OpenAI, and Google APIs _and_ over the [WordPress 7.0 AI Client](https://wpwebhooks.org/blog/wordpress-ai-client-wp-ai-client-prompt/), which has no native tool-calling at all. Provider portability also keeps model choice a cost decision — see what [one AI call actually costs per provider](https://wpwebhooks.org/blog/llm-api-pricing-cost-per-call/). JSON — the envelope the model must return ``` { "assistant_message": "I'll send new WooCommerce orders to your n8n workflow.", "clarifying_questions": ["What is the n8n webhook URL?"], "plan": [ { "id": "step_1", "ability": "create_webhook", "summary": "Create the order webhook (disabled)", "input": { "name": "Orders to n8n", "endpoint_url": "" } }, { "id": "step_2", "ability": "test_dispatch", "summary": "Send a test delivery", "input": { "webhook_id": "{{step_1.id}}" } } ] } ``` Three details make this production-grade rather than a demo. Steps are _typed_: each names one ability from a fixed catalog, and its input must match that ability's JSON Schema — the model cannot invent operations. Steps can _reference earlier results_: `{{step_1.id}}` is substituted with the real id at run time, so the model can chain a build without knowing database ids. And parsing is _defensive_: strip code fences, fall back to the outermost brace span, and if the reply still is not valid JSON, treat the whole text as a plain assistant message with no plan — a malformed reply degrades to conversation, never to a broken mutation. / Safety ## How does step-by-step execution **stay safe**? Safety lives in the executor, not in the prompt. The prompt asks the model to behave; the executor makes misbehaviour inert: 1. **New webhooks are created disabled.** The agent can build a complete integration, but nothing fires until a human flips it live. 2. **Destructive steps carry confirmation metadata.** Enabling, deleting, or editing a live webhook always pauses for explicit confirmation; an endpoint probe pauses only when its HTTP method is unsafe. 3. **Steps run one at a time.** The frontend advances the plan step by step; missing required input, an unmet prerequisite, or a needed confirmation pauses the run at that exact step. 4. **Failure halts, never cascades.** A failed step stops the run with the error attached; later steps are never attempted against a half-built state. Everything also passes the same capability checks as the admin UI — the agent holds no special powers, and credential values stay in a write-only vault the agent can reference but [never read](https://wpwebhooks.org/blog/wordpress-abilities-api-ai-agents/). > The model never calls tools natively — it proposes typed steps, and the plugin executes them locally. The site stays in control. — the core contract ![Cyberpunk isometric architecture map titled “Build an AI agent in a WordPress plugin: Architecture” — a glowing neon brain on a CPU socket at the center, circuit traces connecting it to holographic blocks labelled Front-end Interface, API Endpoints, Agent Logic and LLM Integration, with WordPress logos on nearby panels.](https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/og_image.jpg) / Undo ## How do **undo and revert** work? Before the executor mutates anything, it snapshots the object's prior state onto the step record. Undo then walks the applied steps backwards: the last still-applied revertible step is found, its pre-state restored — or the object it created deleted — and the step marked reverted. Repeated calls walk further back, giving you a real undo stack rather than a single "oops" button. One subtle but important touch: each undo is recorded into the conversation transcript. The model sees what was undone on its next turn, so it does not confidently reference a webhook that no longer exists. / Debugging ## How do you **debug an agent** you cannot see? With a trace, or not at all. Every model call records the provider and model that answered, latency, temperature, the full system prompt, the exact message array sent, the raw response, whether the JSON envelope parsed, and how many plan steps it contained. When a user reports "the AI did something weird", that trace is the difference between a fix and a shrug: a parse failure, a provider quota error, and a genuinely bad plan all look identical from the chat UI but completely different in the trace. The trace matters double when your transport can silently switch models. With [cross-provider fallback](https://wpwebhooks.org/blog/wordpress-ai-client-wp-ai-client-prompt/) in play, "which model actually produced this plan?" must be answerable per request, not per configuration. / Toolset ## What does the agent **actually control**? A fixed registry of 17 typed abilities — the same toolset the plugin publishes to the [WordPress Abilities API](https://developer.wordpress.org/apis/abilities-api/) for external clients like Claude Code over [MCP](https://www.anthropic.com/news/model-context-protocol). One catalog of operations, one permission story, three consumers: the in-admin agent, the REST surface, and MCP tooling (the [Abilities API article](https://wpwebhooks.org/blog/wordpress-abilities-api-ai-agents/) covers that side). The system prompt also injects a compact catalog of the webhooks that already exist on the site, flagged with their numeric ids — so when the user says "rename the order webhook", the agent proposes an update to webhook #12 instead of creating a duplicate. Grounding the model in current state is as much a safety feature as the confirmation gates. | Concern | Chatbot that "just does it" | Plan-first agent (Build with AI) | | --- | --- | --- | | Mutations | Model output parsed and applied immediately | Typed plan, reviewed and edited, stepped through | | Destructive actions | One hallucination from deleting live config | Created disabled + explicit confirm to go live or delete | | Failure mid-task | Half-applied state, no rollback | Run halts; undo stack restores pre-state snapshots | | Auditability | A chat log | Per-step activity log + full model trace | → This architecture powers _Build with AI_, coming in the next major release of the [Webhook Actions plugin](https://wpwebhooks.org/wordpress-webhook-plugin/): describe the integration you want, review the plan, and it creates queued, retried, fully logged webhooks — disabled until you say go. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) /Footnotes ¹ The Webhook Actions plugin on WordPress.org: [wordpress.org/plugins/flowsystems-webhook-actions](https://wordpress.org/plugins/flowsystems-webhook-actions/). ² Abilities API: [github.com/WordPress/abilities-api](https://github.com/WordPress/abilities-api); PHP AI Client SDK: [github.com/WordPress/php-ai-client](https://github.com/WordPress/php-ai-client). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Build an AI Agent in a WordPress Plugin: Architecture","description":"Inside an in-admin AI agent for WordPress: plan-first JSON envelopes, typed ability steps, confirmation gates, undo stacks, and trace logging.","datePublished":"2026-06-26","dateModified":"2026-06-26","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk isometric architecture map titled “Build an AI agent in a WordPress plugin: Architecture” — a glowing neon brain on a CPU socket at the center, circuit traces connecting it to holographic blocks labelled Front-end Interface, API Endpoints, Agent Logic and LLM Integration, with WordPress logos on nearby panels."},"keywords":["wordpress ai agent","ai agent plugin architecture","plan first ai agent","wordpress ai builder","llm json envelope protocol","ai agent undo stack"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Build an AI Agent in a WordPress Plugin: Architecture","item":"https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is a plan-first AI agent?","acceptedAnswer":{"@type":"Answer","text":"An agent that never mutates anything during the conversation. The model replies with a proposal — an assistant message, optional clarifying questions, and an ordered plan of typed steps — and the application executes the plan locally, step by step, after the user reviews and optionally edits it. The model proposes; the plugin disposes."}},{"@type":"Question","name":"How can an AI agent work without native tool calling?","acceptedAnswer":{"@type":"Answer","text":"With a structured-output protocol: the model is instructed to return a single strict JSON envelope containing the message, questions, and plan. Because the protocol lives in the prompt and parser rather than the transport, the same loop runs over the Anthropic, OpenAI, and Google APIs and over the WordPress 7.0 AI Client, which has no tool-calling."}},{"@type":"Question","name":"How do you stop an AI agent from breaking a production site?","acceptedAnswer":{"@type":"Answer","text":"Structurally, not with prompt engineering alone. New webhooks are created disabled, destructive steps (enable, delete, edit a live webhook) pause for explicit confirmation, steps execute one at a time and halt on failure, every invocation passes the same capability checks as the admin UI, and every mutation snapshots prior state onto an undo stack."}},{"@type":"Question","name":"How does undo work in an AI agent?","acceptedAnswer":{"@type":"Answer","text":"Before each mutation, the executor snapshots the object's prior state onto the step record. Undo walks the applied steps backwards, restoring each pre-state or deleting created objects, and marks the step reverted. Each undo is also written into the conversation transcript so the model knows on its next turn."}},{"@type":"Question","name":"Can external AI tools drive the same agent toolset?","acceptedAnswer":{"@type":"Answer","text":"Yes — by design: the agent's abilities are also published to the WordPress Abilities API under the plugin's namespace, so external MCP clients like Claude Code and Cursor can discover and invoke the identical operations over REST or the MCP Adapter — one toolset, one permission story, three consumers."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/ai-agent-plan-loop.png","caption":"FIG 01 — The plan-first agent loop","description":"The user describes a goal in chat. The model replies with a strict JSON envelope containing an assistant message, optional clarifying questions, and an ordered plan of typed ability steps. The user reviews and can edit the plan. The plugin then executes steps one at a time: destructive steps pause for explicit confirmation, every mutation snapshots the prior state onto an undo stack, and a failed step halts the run without touching later steps.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "ActionScheduler_DBStore Deadlock: The Fix & Resolution" description: "ActionScheduler_DBStore::claim_actions throws MySQL deadlocks under concurrent WooCommerce queue runners — why it happens and the exact fix." url: "https://wpwebhooks.org/blog/woocommerce-action-scheduler-mysql-deadlocks/" date: "2026-06-25" --- # ActionScheduler_DBStore Deadlock: The Fix & Resolution **TL;DR:** Action Scheduler deadlocks come from the `UPDATE` inside `claim_actions()` colliding when two or more queue runners reserve overlapping rows in `wp_actionscheduler_actions` at the same instant. - Fastest fix: set `action_scheduler_queue_runner_concurrent_batches` to 1 so one runner claims at a time, and use a smaller batch size to hold the lock for less time. - Make sure you are on Action Scheduler 3.2.0 or newer — it added an index that cut claim-query lock contention. - A MySQL deadlock is transient, not data loss: InnoDB rolls back one transaction and expects a retry. Do not treat error 1213 as fatal. / Root cause ## Why does Action Scheduler throw **MySQL deadlocks**? Because two or more queue runners try to claim overlapping rows in `wp_actionscheduler_actions` in the same moment. Each runner issues a single statement like `UPDATE wp_actionscheduler_actions SET status = 'in-progress', claim_id = N ... ORDER BY ... LIMIT 25`. InnoDB takes row and gap locks in the order rows match the query. When two runners lock the same rows in opposite order, InnoDB detects the cycle and kills one transaction with `Deadlock found when trying to get lock; try restarting transaction` — MySQL error 1213. On a busy WooCommerce store the pending table is large and several runners fire every minute, so the window where two claim queries overlap is wide. This is a documented, intermittent symptom rather than a corruption bug — see the long-running ["Diagnose intermittent deadlocks" issue](https://github.com/woocommerce/action-scheduler/issues/530) on the Action Scheduler tracker. The claim mechanism itself — the `claim_id` column and the dedicated claims table those runners write to — is detailed in the [Action Scheduler database schema](https://wpwebhooks.org/blog/action-scheduler-database-tables/). / The mechanism ## What are **claim\_actions() and release\_claim()**? The claim is Action Scheduler's concurrency primitive. `ActionScheduler_DBStore::claim_actions()` reserves a batch: it stamps a unique `claim_id` onto up to `batch_size` pending rows and flips them to `in-progress` in one UPDATE. That stamp is what lets several runners work the same queue without ever executing the same action twice. When the batch finishes — or the runner process dies — `release_claim()` clears that `claim_id` so any unprocessed actions return to the pool for another runner. The important detail for debugging: the deadlock lives in the claim UPDATE, not in your action callback. The callback never ran; the runner could not even reserve its work. You can read both methods in the [ActionScheduler\_DBStore source](https://github.com/woocommerce/action-scheduler/blob/trunk/classes/data-stores/ActionScheduler_DBStore.php). FIG 01 — Two runners colliding on the same claim window / Multipliers ## What makes **concurrent runners collide**? Three things widen the collision window. First, `action_scheduler_queue_runner_concurrent_batches` greater than 1 dispatches several loopback runners per cron tick — each one issues its own claim UPDATE. Second, double-driven cron: a traffic-triggered WP-Cron and a system cron can both spawn runners at the same time. Third, a slow claim query holds its locks longer. That last one is the quiet killer. When the pending table is large or missing the right index, the claim UPDATE scans more rows and keeps gap locks open longer, so overlapping runners are far more likely to deadlock or hit lock-wait timeouts — the exact behaviour tracked in [issue #1104, "Slow claim actions update query causing lock wait timeouts"](https://github.com/woocommerce/action-scheduler/issues/1104). More pending rows plus a longer UPDATE equals more overlap. > A MySQL deadlock is not data loss. InnoDB rolls back one transaction and expects you to retry it. The bug is treating error 1213 as fatal. — InnoDB locking model / The fix ## How do I **stop the deadlocks** safely? Work the cheapest, highest-leverage fixes first. Most stores stop seeing 1213 after the first two. 1. **Upgrade Action Scheduler to 3.2.0 or newer.** That release added an index to the actions table specifically to reduce claim-query contention — see the [Action Scheduler 3.2.0 release notes](https://developer.woocommerce.com/2021/06/03/action-scheduler-3-2-0-released/). WooCommerce bundles its own copy, so update WooCommerce too. 2. **Drop concurrent batches to 1.** If you do not need the parallel throughput, serialize the claim so only one runner reserves rows at a time. This removes the collision entirely. 3. **Use one cron source.** Disable visitor-triggered WP-Cron and drive the queue from a single system cron, so you never have two schedulers spawning runners on top of each other. 4. **Treat a stray 1213 as transient.** An occasional deadlock under heavy load is expected. Action Scheduler re-claims rolled-back rows on the next tick; alert on sustained spikes, not single events. mu-plugins/as-deadlock-guard.php — serialize the queue runner ``` // wp-content/mu-plugins/as-deadlock-guard.php // One runner claims at a time — removes the claim-overlap deadlock. add_filter( 'action_scheduler_queue_runner_concurrent_batches', function () { return 1; } ); // Smaller batches hold the claim lock for less time. add_filter( 'action_scheduler_queue_runner_batch_size', function () { return 10; } ); ``` / Diagnose first ## How do I **confirm it is a claim deadlock** and not something else? Check the MySQL error log (or your host's slow/error query view) for `Deadlock found when trying to get lock` on an `UPDATE` against `wp_actionscheduler_actions` that sets `status` and `claim_id`. If the failing statement targets that table and column pair, it is the claim. A deadlock inside one of your own action callbacks is a different problem — that points at the work the action does, not at Action Scheduler. For live queue pressure, open **WooCommerce → Status → Scheduled Actions** or run `wp action-scheduler queue-status` as documented in the [WP-CLI reference](https://actionscheduler.org/wp-cli/). A pending count in the thousands that keeps growing tells you the claim is contended and the table is big enough for the slow-query path to bite. | Concern | Hand-tuned Action Scheduler | Webhook Actions | | --- | --- | --- | | Concurrency | Multiple loopback runners claim the same rows, triggering 1213 deadlocks | Single persistent delivery queue with a serialized claim | | On deadlock | Action left in-progress until the claim times out | Automatic retry with exponential backoff, fully logged | | Visibility | grep the MySQL error log to find the failing UPDATE | Per-attempt delivery log in wp-admin with response codes | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / The deeper fix ## Should you **move webhook delivery out of the shared table**? If the deadlocks started after you began enqueuing an outbound HTTP call per order or per form entry, the contention is self-inflicted. You are mixing your delivery workload into WooCommerce's own scheduled-action table, so your webhook claims and WooCommerce's order, email, and sync claims all fight for the same rows. A purpose-built delivery queue with its own tables and a single serialized runner removes that cross-contention — your deliveries never lock against WooCommerce's housekeeping. That is exactly the boundary the [Webhook Actions plugin](https://wpwebhooks.org/wordpress-webhook-plugin/) draws. For the wider failure modes of running delivery on shared cron infrastructure, see [why WordPress webhooks silently fail in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/). → [Webhook Actions](https://wpwebhooks.org/wordpress-webhook-plugin/) keeps outbound delivery in its own queue with smart retry and a full attempt log — so a busy WooCommerce store's scheduled actions and your webhook deliveries never deadlock against each other. /Footnotes ¹ Action Scheduler intermittent deadlock discussion: [github.com/woocommerce/action-scheduler/issues/530](https://github.com/woocommerce/action-scheduler/issues/530). ² Slow claim query / lock-wait timeouts: [github.com/woocommerce/action-scheduler/issues/1104](https://github.com/woocommerce/action-scheduler/issues/1104). ³ Index added to reduce deadlocks in [Action Scheduler 3.2.0](https://developer.woocommerce.com/2021/06/03/action-scheduler-3-2-0-released/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"ActionScheduler_DBStore Deadlock: The Fix & Resolution","description":"ActionScheduler_DBStore::claim_actions throws MySQL deadlocks under concurrent WooCommerce queue runners — why it happens and the exact fix.","datePublished":"2026-06-25","dateModified":"2026-06-25","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/woocommerce-action-scheduler-mysql-deadlocks/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["woocommerce action scheduler mysql deadlock","action scheduler release claim deadlock","action scheduler concurrent queue runners","actionscheduler actions deadlock","action scheduler claim actions lock","woocommerce scheduled actions deadlock"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"ActionScheduler_DBStore Deadlock: The Fix & Resolution","item":"https://wpwebhooks.org/blog/woocommerce-action-scheduler-mysql-deadlocks/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What causes the \"Deadlock found when trying to get lock\" error in WooCommerce?","acceptedAnswer":{"@type":"Answer","text":"It is almost always Action Scheduler. Two or more queue runners issue an UPDATE on wp_actionscheduler_actions to claim pending rows at the same instant, and InnoDB kills one transaction with error 1213 to break the lock cycle. The failing statement sets status to in-progress and stamps a claim_id."}},{"@type":"Question","name":"Is an Action Scheduler deadlock dangerous or does it lose data?","acceptedAnswer":{"@type":"Answer","text":"No. A MySQL deadlock rolls back one transaction cleanly; the rows are untouched and Action Scheduler re-claims them on the next tick. An occasional deadlock under heavy load is expected. Only sustained spikes indicate a real problem worth tuning for."}},{"@type":"Question","name":"How do I reduce Action Scheduler deadlocks?","acceptedAnswer":{"@type":"Answer","text":"Upgrade Action Scheduler to 3.2.0 or newer for the added table index, set action_scheduler_queue_runner_concurrent_batches to 1 to serialize claims, lower the batch size so the claim lock is held briefly, and drive the queue from a single cron source instead of both WP-Cron and a system cron."}},{"@type":"Question","name":"What is the claim_id column in wp_actionscheduler_actions?","acceptedAnswer":{"@type":"Answer","text":"claim_id is how a queue runner reserves a batch of actions. claim_actions() stamps a unique claim_id on up to batch_size pending rows and flips them to in-progress so other runners skip them. release_claim() clears it again when the batch finishes or the runner dies."}},{"@type":"Question","name":"Does upgrading Action Scheduler fix deadlocks?","acceptedAnswer":{"@type":"Answer","text":"It helps significantly. Action Scheduler 3.2.0 added an index to the actions table specifically to reduce claim-query lock contention. Combined with serializing the runner to one concurrent batch, most stores stop seeing error 1213 entirely."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/as-deadlock.png","caption":"FIG 01 — Two runners colliding on the same claim window","description":"Both queue runners issue an UPDATE on the pending wp_actionscheduler_actions rows limited to 25 rows and lock the same rows. InnoDB detects the lock cycle and rolls back one transaction with error 1213 while the other claim succeeds.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "as_has_scheduled_action: Prevent Duplicate Actions" description: "as_has_scheduled_action() checks whether a matching action is already pending — signature, the hook, args and group parameters, return value, and dedup gotchas." url: "https://wpwebhooks.org/blog/action-scheduler-prevent-duplicate-actions/" date: "2026-06-22" --- # as_has_scheduled_action: Prevent Duplicate Actions **TL;DR:** Action Scheduler gives you two built-in ways to stop duplicate jobs — the `$unique` flag on the scheduling functions, and a guard with `as_has_scheduled_action()`. - `$unique = true` skips the insert if a matching action is already pending or in-progress, returning 0 instead of an action ID. - `as_has_scheduled_action( $hook, $args, $group )` (since 3.3.0) returns true when a matching action already exists — match is exact on hook, args, and group. - Neither is fully race-proof. `$unique` collapses the check and the insert into one call, so prefer it for events that can fire twice. / Two approaches ## How do you **prevent duplicate scheduled actions** in Action Scheduler? There are two supported approaches, and both compare actions on the same triple: hook name, arguments array, and group. The first is the `$unique` parameter on the scheduling functions — set it to true and Action Scheduler refuses to create a second action that matches one already queued. The second is `as_has_scheduled_action()`, which lets you check whether a matching action exists and branch on the result before you schedule. Use `$unique` when a single user event can fire the same enqueue twice. Use `as_has_scheduled_action()` when you set up a recurring action once and want to avoid stacking it on every plugin upgrade. The full signatures live in the [Action Scheduler API reference](https://actionscheduler.org/api/). / The flag ## What does the **$unique parameter** do? `$unique` is a boolean argument on `as_enqueue_async_action()`, `as_schedule_single_action()`, and `as_schedule_recurring_action()`. When it is true, Action Scheduler will not create the action if one with the same hook, args, and group is already pending or in-progress — the call returns 0 rather than a new action ID. Because the uniqueness check happens inside the same call that performs the insert, it is the tightest guard the API offers. Note the argument order: `$unique` sits after `$group` and before `$priority`. If you only ever passed a hook and args before, you have to fill the `$group` slot (even with an empty string) to reach it. PHP — signatures and a unique enqueue ``` // Signatures (Action Scheduler core) as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false, $priority = 10 ); as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ); // At most one sync per order can ever be pending: as_schedule_single_action( time() + 300, 'sync_order', array( $order_id ), 'orders', true // $unique ); ``` / The check ## How does **as\_has\_scheduled\_action()** check for duplicates? It returns true if at least one action with the given hook — and optionally the given args and group — is currently pending or in-progress. It was added in Action Scheduler 3.3.0 and is the recommended way to guard recurring schedulers you register during activation or upgrade routines, so a plugin update never stacks a second copy of the same five-minute job. Matching is exact. The args you pass must equal the args you scheduled with, element for element. Omit the args entirely to match any action on that hook. The Action Scheduler [usage guide](https://actionscheduler.org/usage/) shows the canonical guard pattern. PHP — guard a recurring action on activation/upgrade ``` // Run on init / activation — never stack the recurring action. if ( ! as_has_scheduled_action( 'my_cron_5min' ) ) { as_schedule_recurring_action( time(), 5 * MINUTE_IN_SECONDS, 'my_cron_5min' ); } ``` FIG 01 — Deduplication decision path / Which one ## **$unique vs as\_has\_scheduled\_action** — which should you use? Use `$unique` for one-off enqueues fired from request handlers — order placed, form submitted, webhook received — where the same event can realistically fire twice in quick succession. Use `as_has_scheduled_action()` for recurring actions you register once in activation or upgrade code. The reason is the race window. `as_has_scheduled_action()` is a check-then-act: another process can insert a matching action in the gap between your check returning false and your schedule call running. `$unique` has no gap — the check and the insert are one operation, so two concurrent requests cannot both win. | Scenario | Raw dedup in your code | Webhook Actions | | --- | --- | --- | | Same event fires twice | Two actions unless you set $unique or guard manually | Idempotent dispatch — one delivery per event | | Recurring setup re-runs on upgrade | Guard every time with as\_has\_scheduled\_action | Trigger bound once in the admin UI | | Race between check and insert | Possible with check-then-schedule patterns | A single queued row per event | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Edge cases ## Why do **duplicates still slip through**? Four reasons cover almost every case. First, args mismatch: a single differing argument — an integer where you later pass a string, an extra element, a different order — makes the triple look unique, so normalize args before scheduling and checking. Second, group mismatch: checking without the group you scheduled with. Third, a genuine race when two concurrent requests both pass `as_has_scheduled_action()` before either inserts — the case `$unique` exists to close. Fourth, and the one that surprises people: completed actions do not count. `as_has_scheduled_action()` only sees pending and in-progress actions, so a single action that already ran will not block you from scheduling it again. That is usually the behaviour you want, but it means it is not a historical "has this ever run" check. > Action Scheduler's idea of "the same action" is an exact match on hook, args, and group. Change one byte of the args array and it is a brand-new action. — Action Scheduler API → The [Webhook Actions plugin](https://wpwebhooks.org/wordpress-webhook-plugin/) binds a trigger to a webhook once in the admin UI and dispatches one delivery per event — so you get idempotent outbound webhooks without writing `$unique` guards by hand. See the [Action Scheduler PHP API reference](https://wpwebhooks.org/blog/action-scheduler-api-functions/) for the rest of the scheduling surface. /Footnotes ¹ Function signatures: [actionscheduler.org/api](https://actionscheduler.org/api/). ² Recurring-action guard pattern and the 3.3.0 note for `as_has_scheduled_action()`: [actionscheduler.org/usage](https://actionscheduler.org/usage/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"as_has_scheduled_action: Prevent Duplicate Actions","description":"as_has_scheduled_action() checks whether a matching action is already pending — signature, the hook, args and group parameters, return value, and dedup gotchas.","datePublished":"2026-06-22","dateModified":"2026-06-22","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/action-scheduler-prevent-duplicate-actions/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["action scheduler unique parameter","as has scheduled action","action scheduler prevent duplicate actions","as enqueue async action unique","as schedule single action unique","action scheduler duplicate jobs"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"as_has_scheduled_action: Prevent Duplicate Actions","item":"https://wpwebhooks.org/blog/action-scheduler-prevent-duplicate-actions/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do I stop Action Scheduler from scheduling duplicate actions?","acceptedAnswer":{"@type":"Answer","text":"Use one of two built-in tools. Pass $unique = true to as_enqueue_async_action(), as_schedule_single_action(), or as_schedule_recurring_action() so a matching action is not created twice, or guard the schedule call with if ( ! as_has_scheduled_action(...) ). Both match on hook, args, and group."}},{"@type":"Question","name":"What is the $unique parameter in as_schedule_single_action?","acceptedAnswer":{"@type":"Answer","text":"It is a boolean that, when true, tells Action Scheduler not to create the action if one with the same hook, args, and group is already pending or in-progress. The call returns 0 instead of an action ID. Because the check and insert happen in one call, it is race-safe."}},{"@type":"Question","name":"What does as_has_scheduled_action() return?","acceptedAnswer":{"@type":"Answer","text":"It returns true if at least one action matching the given hook (and optional args and group) is currently pending or in-progress, and false otherwise. It was added in Action Scheduler 3.3.0 and is the recommended guard for recurring actions registered during activation or upgrade."}},{"@type":"Question","name":"Does as_has_scheduled_action check completed actions?","acceptedAnswer":{"@type":"Answer","text":"No. It only considers pending and in-progress actions. A single action that already ran to completion will not block you from scheduling it again, so it is not a historical \"has this ever run\" check — it answers \"is one queued right now\"."}},{"@type":"Question","name":"Why is as_has_scheduled_action still creating duplicates?","acceptedAnswer":{"@type":"Answer","text":"Usually an args or group mismatch — matching is exact, so a different type, an extra element, or a different order makes the action look unique. It can also be a genuine race: two concurrent requests both pass the check before either inserts. Use the $unique flag to close that race."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/as-dedup.png","caption":"FIG 01 — Deduplication decision path","description":"A schedule request carrying a hook, args, and group is checked with the unique flag or an as_has_scheduled_action guard. If a matching action already exists the insert is skipped and zero is returned; otherwise a new action is created and its id is returned.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Webhook Retry: Exponential Backoff & Database Schema" description: "Design a webhook retry policy: exponential backoff timing, what to retry, max attempts, dead-lettering, and the database schema to track deliveries." url: "https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/" date: "2026-06-19" --- # Webhook Retry: Exponential Backoff & Database Schema **TL;DR:** A webhook retry policy answers three questions — which failures to retry, how long to wait between attempts, and when to give up. - Retry only what can succeed later: 5xx and 429 yes; 4xx (except 429) and 3xx no — fail fast. - Use exponential backoff with a ceiling and jitter, e.g. 30s, 1m, 2m, 4m, 8m capped at 1 hour over ~5 attempts. - Track every attempt in a deliveries plus attempts schema so you can replay, dead-letter, and report a real success rate. / The policy ## What is a good **webhook retry policy**? A retry policy is the set of rules that decide what happens after a delivery fails. A sane default has four moving parts: retry on 5xx, 429, and network or timeout errors; back off exponentially with a ceiling so you stop hammering a struggling endpoint; cap the total at around five attempts spread over a few hours; then move the delivery to a dead-letter state for manual replay instead of retrying forever. Everything else is tuning. The numbers below are a starting point, not a law — a payment provider and an internal analytics sink deserve different ceilings — but the shape is the same across almost every production webhook system, from [Stripe's webhook delivery](https://docs.stripe.com/webhooks) down to a single WordPress site. / Backoff ## Why **exponential backoff** instead of fixed intervals? Because the failure is usually the receiver being briefly overloaded or down. A fixed-interval retry hammers a struggling endpoint at a constant rate and can keep it down; exponential backoff gives the receiver geometrically more time to recover on each attempt while still retrying quickly for a one-second blip. Add jitter — a small random offset on each delay — so a fleet of senders that all failed at the same moment does not re-fire in lockstep and create a thundering herd at every interval boundary. Even on a single site, jitter spreads retries off the exact minute mark and away from the rest of your cron load. FIG 01 — Delivery retry state machine / The schedule ## What **backoff schedule** should you use? Double the delay each attempt from a small base, cap it, and stop after a fixed count. A widely used shape is base 30 seconds, factor 2, ceiling 1 hour: roughly 30s, 1m, 2m, 4m, 8m, then any further attempts pinned at the cap. The formula is `delay = min(cap, base * 2 ^ (attempt - 1))`, plus a jitter term. This is exactly the schedule the [Webhook Actions plugin](https://wordpress.org/plugins/flowsystems-webhook-actions/) ships: 5xx and 429 responses retry with delays of about 30s, 60s, 120s, 240s, and 480s, capped at one hour, with a default of five attempts that you can override with the `fswa_max_attempts` filter. PHP — exponential backoff with jitter ``` function next_retry_delay( int $attempt ): int { $base = 30; // seconds $cap = 3600; // 1 hour ceiling $delay = min( $cap, $base * ( 2 ** ( $attempt - 1 ) ) ); // add up to 20% jitter so retries do not synchronize return $delay + random_int( 0, (int) ( $delay * 0.2 ) ); } ``` / The schema ## What **database schema** tracks retries? Two tables. A `deliveries` row represents one logical webhook send and carries the state machine; an `attempts` row records each physical HTTP try so you keep the full request and response history. Splitting them keeps the hot path (find due deliveries) on a small, well-indexed table while the verbose request and response bodies live separately. The columns that matter on `deliveries`: an `event_id` as the idempotency key, the endpoint and payload, a `status` enum (queued, sending, delivered, failed, dead), an `attempt_count`, and a `next_attempt` timestamp the runner queries against. Index on `(status, next_attempt)` so "what is due now" stays fast as the table grows. SQL — deliveries table (the hot path) ``` CREATE TABLE wp_webhook_deliveries ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, event_id CHAR(36) NOT NULL, -- idempotency key endpoint_url VARCHAR(512) NOT NULL, payload LONGTEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'queued', attempt_count SMALLINT NOT NULL DEFAULT 0, next_attempt DATETIME NULL, created_at DATETIME NOT NULL, UNIQUE KEY uq_event (event_id), KEY due (status, next_attempt) ); ``` ![Cyberpunk illustration of a webhook retry policy: a “source” server sends a delivery through a green “retry” loop toward an offline “destination” node showing a red error, while neon “BACKOFF” paths labelled WAIT 1s, 4s, 16s trace an exponential backoff schedule beside a screen validating the payload schema.](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/og_image.jpg) / What to retry ## Which **responses should trigger a retry**? Retry 5xx and 429; never retry 4xx (except 429) or 3xx. A 5xx means the receiver broke on a request that may be fine next time. A 429 means slow down — back off and try again. A 4xx like 400 or 422 means the payload itself is wrong, so retrying just fails again more slowly; mark it permanently failed and surface it. A 3xx redirect is a configuration problem, not a transient one. Treat network-level failures — DNS, connection refused, timeouts — like 5xx: retry them. They are the most common transient failure of all, and a request that timed out may well have one foot in the door, which is exactly why the next section matters. | Response | Hand-rolled wp\_remote\_post | Webhook Actions | | --- | --- | --- | | 5xx / timeout | Retried only if you wrote the loop yourself | Retried with exponential backoff automatically | | 429 Too Many Requests | Usually ignored and treated as success | Retried, respecting the backoff schedule | | 4xx / 3xx | Often blindly retried, wasting cycles | Marked permanently\_failed immediately | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / When to stop ## How do you **stop retrying** — dead-letter and idempotency? Cap the attempts. When a delivery exhausts its budget, set its status to dead and surface it for manual replay rather than retrying forever; an endpoint that has failed five times over an hour is down, not flaky. A dead-letter state turns an invisible silent failure into a queue someone can actually action. Pair retries with an idempotency key — the `event_id` column above — sent as a header so the receiver can dedupe. Any retry policy is at-least-once delivery by definition: a request that timed out may have succeeded on the receiver before your timeout fired, so a retry delivers it twice. The key lets the receiver collapse those duplicates, the same pattern Stripe documents for its own webhooks. For a WordPress-specific build of this whole loop, see the [retry and replay system walkthrough](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/). > Retrying a 400 is just a slower way to fail. Backoff is for problems that time can fix. — Retry design, in one line → [Webhook Actions](https://wpwebhooks.org/wordpress-webhook-plugin/) implements this policy out of the box: exponential-backoff retries on 5xx and 429, a per-attempt delivery log, dead-letter visibility, and one-click replay — no schema or backoff code to maintain. /Footnotes ¹ Webhook delivery, retries, and idempotency guidance: [docs.stripe.com/webhooks](https://docs.stripe.com/webhooks). ² Retry schedule and `fswa_max_attempts` filter: [Webhook Actions on WordPress.org](https://wordpress.org/plugins/flowsystems-webhook-actions/). ³ WordPress HTTP request function used to deliver webhooks: [wp\_remote\_post()](https://developer.wordpress.org/reference/functions/wp_remote_post/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Webhook Retry: Exponential Backoff & Database Schema","description":"Design a webhook retry policy: exponential backoff timing, what to retry, max attempts, dead-lettering, and the database schema to track deliveries.","datePublished":"2026-06-19","dateModified":"2026-06-19","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of a webhook retry policy: a “source” server sends a delivery through a green “retry” loop toward an offline “destination” node showing a red error, while neon “BACKOFF” paths labelled WAIT 1s, 4s, 16s trace an exponential backoff schedule beside a screen validating the payload schema."},"keywords":["webhook retry policy","exponential backoff webhook","webhook retry database schema","cron job retry policy","webhook delivery retry schema","webhook backoff strategy"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Webhook Retry: Exponential Backoff & Database Schema","item":"https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is exponential backoff for webhooks?","acceptedAnswer":{"@type":"Answer","text":"Exponential backoff doubles the wait between retry attempts from a small base up to a ceiling — for example 30s, 1m, 2m, 4m, 8m capped at 1 hour. It gives a failing receiver geometrically more time to recover on each attempt while still retrying quickly for brief blips. Add jitter so senders do not retry in lockstep."}},{"@type":"Question","name":"Which HTTP responses should trigger a webhook retry?","acceptedAnswer":{"@type":"Answer","text":"Retry 5xx server errors, 429 Too Many Requests, and network or timeout failures — these can succeed later. Do not retry other 4xx responses (like 400 or 422) or 3xx redirects: those mean the request itself is wrong, so retrying just fails again. Mark them permanently failed and surface them."}},{"@type":"Question","name":"How many times should you retry a webhook?","acceptedAnswer":{"@type":"Answer","text":"A common default is about five attempts spread over a few hours via exponential backoff. After the budget is exhausted, move the delivery to a dead-letter state for manual replay rather than retrying forever — an endpoint that has failed five times over an hour is down, not flaky."}},{"@type":"Question","name":"What database schema should I use to track webhook deliveries?","acceptedAnswer":{"@type":"Answer","text":"Use two tables: a deliveries row per logical send (event_id, endpoint, payload, status, attempt_count, next_attempt) and an attempts row per physical HTTP try (response code, body, duration, error). Index deliveries on (status, next_attempt) so finding due deliveries stays fast."}},{"@type":"Question","name":"Why do I need an idempotency key with retries?","acceptedAnswer":{"@type":"Answer","text":"Any retry policy is at-least-once delivery: a request that timed out may have already succeeded on the receiver, so a retry delivers it twice. Sending a stable idempotency key (an event_id) as a header lets the receiver detect and collapse duplicates safely."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/webhook-retry.png","caption":"FIG 01 — Delivery retry state machine","description":"A queued delivery moves to sending. A 2xx response marks it delivered. A 5xx, 429, or timeout schedules a backoff retry while attempts remain, and dead-letters for manual replay once they are exhausted. A 4xx other than 429, or a 3xx, marks the delivery permanently failed.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Gravity Forms Hooks: Developer Reference & Examples" description: "A developer reference to the most useful Gravity Forms hooks: submission, validation, field, entry, and notification filters — with examples." url: "https://wpwebhooks.org/blog/gravity-forms-hooks-reference/" date: "2026-06-18" --- # Gravity Forms Hooks: Developer Reference & Examples **TL;DR** - Gravity Forms exposes its lifecycle through **actions** (do something when an event fires) and **filters** (modify a value before the plugin uses it). - The hooks you reach for most: `gform_pre_submission`, `gform_validation`, `gform_after_submission`, `gform_after_update_entry`, and the field filters `gform_pre_render` and `gform_field_value_*`. - To fire a webhook on submit, hook `gform_after_submission` — it runs once the entry is saved, so you have the entry ID and all field values. - The [Webhook Actions plugin](https://wpwebhooks.org/wordpress-webhook-plugin/) binds to these hooks from the admin UI, so you get async delivery, retries, and logs without writing the dispatch code yourself. / Overview ## What are Gravity Forms hooks? Gravity Forms hooks are the WordPress actions and filters the plugin fires as a form moves through its lifecycle — render, validation, submission, entry storage, notification, and confirmation. You attach a callback with `add_action()` or `add_filter()` and run your own logic at exactly the right moment, without editing the plugin. There are hundreds of them. In practice a handful cover almost every integration: validate input, modify a field before it renders, react after an entry is saved, and react when an entry is later edited. Everything below is organized by where it fires in the lifecycle, with the canonical Gravity Forms documentation linked for each¹. FIG 01 — Gravity Forms submission hook order / Actions vs filters ## How do Gravity Forms actions and filters differ? Actions let you _do_ something; filters let you _change_ something. An action callback receives data and returns nothing — `gform_after_submission` hands you the saved entry so you can send it somewhere. A filter callback receives a value and must return it (modified or not) — `gform_pre_render` hands you the form array and expects the form array back. The practical rule: if your callback forgets to `return` inside a filter, you will blank out the value Gravity Forms was about to use. Actions have no such trap. When in doubt, the docs label each hook as an action or a filter at the top of its page². | Hook | Type | Fires when | Use it for | | --- | --- | --- | --- | | gform\_pre\_render | Filter | Before the form is rendered | Inject choices, hide fields, set defaults | | gform\_field\_validation | Filter | Per field, on submit | Reject a single field | | gform\_validation | Filter | On submit, across all fields | Reject a submission with custom logic | | gform\_pre\_submission | Action | After validation, before save | Mutate $\_POST values | | gform\_after\_submission | Action | After the entry is saved | Webhooks, CRM sync, analytics | | gform\_after\_update\_entry | Action | An existing entry is edited | Propagate edits downstream | | gform\_confirmation | Filter | After save, before display | Change the confirmation or redirect | | gform\_pre\_send\_email | Filter | Before a notification is sent | Rewrite or abort the email | / Submission ## Which hooks fire when a form is submitted? Three matter most, in order. `gform_pre_submission` runs after validation passes but before the entry is saved — the last point you can mutate `$_POST['input_5']` values. `gform_after_submission` runs once the entry exists in the database, giving you the complete `$entry` array (including `$entry['id']`) and the `$form` definition. `gform_after_email` fires after notifications are sent. Use `gform_after_submission` for almost all outbound integrations — CRM sync, webhook dispatch, analytics — because the entry is persisted and has a stable ID to deduplicate on. PHP — react after a submission is saved ``` // Fires once the entry is in the database. $entry has the ID + all fields. add_action( 'gform_after_submission', function( $entry, $form ) { $email = rgar( $entry, '3' ); // field ID 3 $entry_id = $entry['id']; // hand off to your integration here }, 10, 2 ); ``` / Validation ## How do you validate submissions with gform\_validation? Use `gform_validation` to reject a submission server-side based on logic Gravity Forms cannot express in the field settings — a remote API check, a duplicate-entry lookup, a business rule across multiple fields. The filter hands you a `$validation_result` array; set `['is_valid'] = false`, mark the offending field's `failed_validation`, and return the array. For single-field checks, `gform_field_validation` is narrower and simpler. Both run before the entry is saved, so a failed validation means no entry, no notifications, and no `gform_after_submission` — which is exactly why validation is the right place to stop bad data, not a post-submission webhook. > Validation hooks run before the entry is saved — so rejecting bad input there means it never reaches your downstream systems in the first place. / Fields ## How do you change field values and choices before render? `gform_pre_render` is the filter for modifying a form on the way to the screen — inject choices into a dropdown, hide a field, or change a default. It receives the `$form` array and must return it. For pre-populating a single field from a query string or dynamic source, the field-specific `gform_field_value_{parameter_name}` filter is more precise and avoids rebuilding the whole form array³. Both are filters, so the cardinal rule applies: always return the value. A common mistake is calling `gform_pre_render` to read the form and forgetting to return it, which renders an empty form. ![Cyberpunk illustration titled “Gravity Forms Hooks — Developer Reference & Examples” showing a developer at a holographic console beside a glowing “Gravity Forms Hooks” database cylinder, labelled with apply_filters(), do_action(), gform_pre_render and gform_after_submission.](https://wpwebhooks.org/blog/gravity-forms-hooks-reference/og_image.jpg) / Entries ## Which hooks fire when an entry is created or updated? `gform_after_submission` covers new entries. For edits, `gform_after_update_entry` fires when an existing entry is changed from the wp-admin entry editor or via the GFAPI — and it hands you both the updated entry and the `$original_entry`, so you can diff fields and only act on real changes. This pairing is what lets you keep a downstream CRM mirrored rather than just snapshotting it at submit time. We cover both in depth: the [gform\_after\_submission webhook guide](https://wpwebhooks.org/blog/gravity-forms-gform-after-submission-webhook/) for new submissions, and the [gform\_after\_update\_entry guide](https://wpwebhooks.org/blog/gravity-forms-gform-after-update-entry-webhook/) for edits and deduplication. / Notifications ## How do you customize confirmations and notifications? `gform_confirmation` filters what the user sees after submitting — swap the confirmation message, change the redirect URL conditionally, or return a different confirmation per logic branch. `gform_pre_send_email` filters the notification email immediately before it is sent, letting you rewrite recipients, subject, or body, or abort the send entirely by setting `$email['abort_email'] = true`. Both are filters and both fire after the entry is saved, so they are safe places to read final entry values but the wrong place to do outbound integration work — use `gform_after_submission` for that. / Webhooks ## Which hook should you use to fire a webhook? Use `gform_after_submission` for new submissions and `gform_after_update_entry` for edits. Both fire after the entry is persisted, so the payload has a stable entry ID and complete field values — everything a receiving system needs to deduplicate and process the event. Firing from a pre-save hook risks sending data for a submission that later fails validation. The raw approach is a hook callback that builds a payload and calls `wp_remote_post()`. That works until the endpoint is slow (your form submit blocks on it), returns a 500 (you have no retry), or silently changes (you have no log). The [Gravity Forms to n8n guide](https://wpwebhooks.org/blog/gravity-forms-to-n8n-webhook/) walks through the full pattern. | Capability | Raw hook + wp\_remote\_post | Webhook Actions | | --- | --- | --- | | Setup | Custom PHP per form and hook | Admin UI trigger picker — no code | | Timing | Synchronous — blocks the form submit | Async queue — submit returns immediately | | Retry | None — one attempt only | Exponential backoff, default 5 attempts | | Logs | None — failures are invisible | Per-attempt log with status + response body | | Replay | Not possible without custom tooling | One-click replay from the Logs screen | → The [Webhook Actions plugin](https://wpwebhooks.org/wordpress-webhook-plugin/) turns any `do_action` into a webhook trigger from the admin UI — pick `gform_after_submission`, `gform_after_update_entry`, or any other Gravity Forms hook (even your own custom action), point it at an endpoint, and delivery runs through a background queue with smart retry and exponential backoff (1m → 2m → 4m → 8m, capped at 1 hour; 5 attempts) and a full delivery log — no dispatch code on your side. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) /Footnotes ¹ [Gravity Forms hooks index](https://docs.gravityforms.com/category/developers/hooks/) — the canonical list of actions and filters. ² Per-hook references: [gform\_after\_submission](https://docs.gravityforms.com/gform_after_submission/), [gform\_validation](https://docs.gravityforms.com/gform_validation/), [gform\_pre\_submission](https://docs.gravityforms.com/gform_pre_submission/), [gform\_confirmation](https://docs.gravityforms.com/gform_confirmation/), [gform\_pre\_send\_email](https://docs.gravityforms.com/gform_pre_send_email/). ³ Field filters: [gform\_pre\_render](https://docs.gravityforms.com/gform_pre_render/) and [gform\_field\_value\_{parameter\_name}](https://docs.gravityforms.com/gform_field_value_parameter_name/). Entry editing and the GFAPI: [GFAPI reference](https://docs.gravityforms.com/api-functions/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Gravity Forms Hooks: Developer Reference & Examples","description":"A developer reference to the most useful Gravity Forms hooks: submission, validation, field, entry, and notification filters — with examples.","datePublished":"2026-06-18","dateModified":"2026-06-18","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/gravity-forms-hooks-reference/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/gravity-forms-hooks-reference/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration titled “Gravity Forms Hooks — Developer Reference & Examples” showing a developer at a holographic console beside a glowing “Gravity Forms Hooks” database cylinder, labelled with apply_filters(), do_action(), gform_pre_render and gform_after_submission."},"keywords":["gravity forms hooks","gravity forms hooks reference","gravity forms developer hooks","gravity forms action hooks","gravity forms filter hooks","gravity forms form lifecycle hooks"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Gravity Forms Hooks: Developer Reference & Examples","item":"https://wpwebhooks.org/blog/gravity-forms-hooks-reference/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What are Gravity Forms hooks?","acceptedAnswer":{"@type":"Answer","text":"Gravity Forms hooks are the WordPress actions and filters the plugin fires throughout a form lifecycle — render, validation, submission, entry storage, notification, and confirmation. You attach a callback with add_action() or add_filter() to run custom logic at the right moment without editing the plugin."}},{"@type":"Question","name":"What is the difference between Gravity Forms actions and filters?","acceptedAnswer":{"@type":"Answer","text":"Actions let you do something when an event fires and return nothing — gform_after_submission hands you the saved entry. Filters let you modify a value and must return it — gform_pre_render hands you the form array and expects it back. Forgetting to return inside a filter blanks out the value Gravity Forms was about to use."}},{"@type":"Question","name":"Which Gravity Forms hook fires after a form is submitted?","acceptedAnswer":{"@type":"Answer","text":"gform_after_submission fires once the entry is written to the database, giving you the complete $entry array including the entry ID, plus the $form definition. It is the right hook for outbound integrations like CRM sync and webhook dispatch."}},{"@type":"Question","name":"How do I send a webhook from a Gravity Forms hook?","acceptedAnswer":{"@type":"Answer","text":"Hook gform_after_submission for new entries (or gform_after_update_entry for edits) and call wp_remote_post() with your payload. Both fire after the entry is saved, so the payload has a stable entry ID. The Webhook Actions plugin binds to these hooks from the admin UI and adds async delivery, retries, and logs without dispatch code."}},{"@type":"Question","name":"Where is the official Gravity Forms hooks documentation?","acceptedAnswer":{"@type":"Answer","text":"The canonical list lives at docs.gravityforms.com under the developers/hooks category. Each hook has its own reference page that labels it as an action or a filter and documents its parameters."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/gravity-forms-hooks-reference.png","caption":"FIG 01 — Gravity Forms submission hook order","description":"A form submission first runs validation hooks that accept or reject the input, then gform_pre_submission offers a last chance to mutate the POST data. After the entry is written, gform_after_submission exposes the entry id and field values, followed by notification and confirmation hooks.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WP Webhooks Pro Alternative: Features & Pricing Compared" description: "A WP Webhooks Pro alternative, compared honestly: free tiers, pricing, delivery reliability, retries and logging — what each plugin actually does." url: "https://wpwebhooks.org/blog/wp-webhooks-alternative/" date: "2026-06-16" --- # WP Webhooks Pro Alternative: Features & Pricing Compared **TL;DR** - **WP Webhooks** (by Ironikus) is a mature, bidirectional automation hub — send and receive data, 100+ prebuilt integrations, and no-code Flows. **Webhook Actions** (by Flow Systems) is an outbound-delivery-first plugin: it turns any WordPress `do_action` into a reliable webhook with a persistent retry queue. - Pick **WP Webhooks** if you need inbound webhooks, prebuilt connectors, and visual Flows. - Pick **Webhook Actions** if outbound delivery reliability matters most — its persistent queue, exponential backoff, delivery logs, and one-click replay are all in the free tier. - Both ship a free plugin on WordPress.org. Pro pricing differs: WP Webhooks is $149 / $249 / $499 per year for 1 / 10 / 75 sites; Webhook Actions is $249 / $399 / $649 for the same site counts and bundles hosted AI credits, where WP Webhooks' AI integrations run on an AI provider key you supply and pay for yourself. / Overview ## What is WP Webhooks, and what is the alternative? [WP Webhooks](https://wordpress.org/plugins/wp-webhooks/) is one of the most established webhook plugins for WordPress — 20,000+ active installs and a 4.6-star rating across 54 reviews¹. It is a general-purpose automation hub: it sends WordPress data out, receives data in to run WordPress functions, and chains the two together with no-code Flows. [Webhook Actions](https://wpwebhooks.org/wordpress-webhook-plugin/) takes a narrower, deeper approach. Rather than a catalogue of prebuilt connectors, it treats every WordPress `do_action` as a potential trigger and focuses on getting that event delivered reliably to any HTTP endpoint — with a persistent queue, automatic retries, and a full delivery log². One is a breadth play; the other is a reliability play. The right answer depends on the job. / Direction ## Send, receive, or both? This is the first fork in the decision. WP Webhooks is bidirectional: its "Send Data" triggers push WordPress events outward, and its "Receive Data" actions accept inbound requests to create posts, users, or run other WordPress functions. Flows then connect a trigger to one or more actions visually. Webhook Actions is, by design, outbound-only. It does not receive inbound webhooks — it exists to turn WordPress events into outgoing HTTP requests as dependably as possible. If your project needs WordPress to _accept_ webhooks from another system, WP Webhooks (or the WordPress REST API directly) is the right tool. If you only need WordPress to _emit_ events, the narrower scope is an advantage, not a limitation. FIG 01 — Webhook Actions outbound delivery pipeline / Reliability ## How do the two handle delivery failures? This is where the plugins diverge most. A webhook is only useful if it arrives — and endpoints time out, return 500s, and rate-limit. Webhook Actions builds its whole model around that reality: deliveries run through a persistent queue that retries failures with exponential backoff (~30s, 60s, 120s, 240s, 480s, capped at one hour, five attempts by default), and every attempt is recorded in a delivery log you can inspect and replay². Crucially, the queue, logs, replay, and bulk retry are all in the free tier. With WP Webhooks, delivery logging and the data-mapping engine are [Pro features](https://wp-webhooks.com/pricing/)³. If observability into what was sent, what failed, and the ability to replay it is central to your integration, factor that into the comparison — Webhook Actions surfaces it without a license. > A webhook plugin's real test is not the happy path — it is what happens when the endpoint returns a 500. Retry, logging, and replay are the features you only miss after a delivery silently fails. / Feature matrix ## How do the features compare side by side? | Capability | WP Webhooks (Ironikus) | Webhook Actions (Flow Systems) | | --- | --- | --- | | Direction | Send + receive + Flows | Send (outbound delivery) | | Prebuilt integrations | 100+ services | Any HTTP endpoint (no catalogue) | | Trigger source | Built-in triggers per integration | Any WordPress do\_action | | Delivery logs + replay | Pro | Free | | Retry queue + backoff | Delay & scheduler (Pro) | Free — persistent queue, exponential backoff | | AI / agent access | AI integrations — bring your own Gemini / ChatGPT / OpenRouter key | Build with AI agent (builds webhooks in-admin) on included hosted credits, or your own key + scoped API tokens & Abilities API | | Active installs | 20,000+ | Newer — smaller install base | | Rating | 4.6 / 5 (54 reviews) | 5 / 5 (2 reviews) | | Pro pricing | $149 / $249 / $499 (1 / 10 / 75 sites) | $249 / $399 / $649 (1 / 10 / 75 sites), AI credits included | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Free tier ## What do you get without paying? Both plugins are genuinely useful for free. WP Webhooks' free tier covers unlimited triggers and actions, authentication (API key, Bearer, Basic), multiple data formats, and access to its integration catalogue. Webhook Actions' free tier covers the entire delivery pipeline — queue, retry, delivery logs, replay, bulk retry, and scoped API tokens — across unlimited sites. The practical distinction: WP Webhooks gives you _more connectors_ for free; Webhook Actions gives you _more reliability and observability_ for free. Decide which you would rather not pay for. ![Neon cyberpunk scene titled "WP Webhooks Pro Alternative: Webhook Actions Compared", with two glowing holographic panels comparing webhook plugin features and flows.](https://wpwebhooks.org/blog/wp-webhooks-alternative/og_image.jpg) / Developers ## Which is better for code and AI agents? Webhook Actions leans developer-first. Any `do_action` — core, WooCommerce, ACF, or your own custom hook — becomes a trigger with no per-integration code, and dynamic endpoint URLs are available via the `fswa_webhook_url` filter. Its scoped API tokens let tools like Claude Code or Cursor read delivery logs, retry failures, and toggle webhooks without WordPress admin credentials. As of v2.0, it also ships **Build with AI** — an in-admin AI agent that assembles these integrations for you. Describe the outcome in chat; the agent inspects your site, proposes an editable plan, and on approval builds and tests the webhooks, mappings, conditions, and chains (new webhooks are created disabled, and going live needs explicit confirmation). The same operations are registered as WordPress Abilities, so external AI tools can discover and invoke the toolset over REST and the MCP Adapter. PHP — any do\_action becomes a trigger ``` // Fire your own event; bind it to a webhook from the admin UI. do_action( 'my_app_order_paid', $order_id, $customer ); // Customise the destination per delivery (free) via filter: add_filter( 'fswa_webhook_url', function( $url, $webhook, $payload ) { return add_query_arg( 'tenant', $payload['tenant_id'], $url ); }, 10, 3 ); ``` WP Webhooks approaches extensibility from the other side: its Pro AI integrations (Google Gemini, ChatGPT, OpenRouter) let you call language models inside a Flow. Different problem, different strength — automation logic versus delivery plumbing. / Verdict ## Which should you choose? There is no universal winner — they optimise for different things. Choose **WP Webhooks** when you need inbound webhooks, a large catalogue of prebuilt integrations, or visual Flows to wire automations without code. Choose **Webhook Actions** when your priority is getting outbound events delivered reliably — when retry, delivery logs, and replay are non-negotiable, and you want them on the free tier and across unlimited sites. For the engineering detail behind reliable delivery, see [how a hook becomes a queued, retried HTTPS request](https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/) and the [retry and replay model](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/). /Footnotes ¹ Install count, rating, and feature list from the WP Webhooks plugin page on WordPress.org (accessed June 2026). ² Retry schedule, delivery logs, and replay per the Webhook Actions plugin page; async delivery via [Action Scheduler](https://actionscheduler.org/) when available. ³ WP Webhooks Pro tiers and Pro-only features (Flows, logging, data mapping) from [wp-webhooks.com/pricing](https://wp-webhooks.com/pricing/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WP Webhooks Pro Alternative: Features & Pricing Compared","description":"A WP Webhooks Pro alternative, compared honestly: free tiers, pricing, delivery reliability, retries and logging — what each plugin actually does.","datePublished":"2026-06-16","dateModified":"2026-07-11","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wp-webhooks-alternative/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wp-webhooks-alternative/og_image.jpg","width":1200,"height":630,"caption":"Neon cyberpunk scene titled \"WP Webhooks Pro Alternative: Webhook Actions Compared\", with two glowing holographic panels comparing webhook plugin features and flows."},"keywords":["wp webhooks alternative","wp webhooks pro alternative","wp webhooks vs webhook actions","wordpress webhook plugin comparison","best wordpress webhook plugin","wp webhooks plugin"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WP Webhooks Pro Alternative: Features & Pricing Compared","item":"https://wpwebhooks.org/blog/wp-webhooks-alternative/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is the best WP Webhooks Pro alternative?","acceptedAnswer":{"@type":"Answer","text":"It depends on the job. WP Webhooks is a bidirectional automation hub with 100+ prebuilt integrations and no-code Flows. Webhook Actions is an outbound-delivery-first alternative that turns any WordPress do_action into a reliable webhook with a persistent retry queue, delivery logs, and replay in its free tier."}},{"@type":"Question","name":"What is the difference between WP Webhooks and Webhook Actions?","acceptedAnswer":{"@type":"Answer","text":"WP Webhooks (by Ironikus) sends and receives data, offers prebuilt integrations, and chains them with Flows. Webhook Actions (by Flow Systems) is outbound-only and focuses on reliable delivery — a persistent queue, exponential-backoff retries, full delivery logs, and one-click replay, all free."}},{"@type":"Question","name":"Does Webhook Actions support receiving webhooks like WP Webhooks?","acceptedAnswer":{"@type":"Answer","text":"No. Webhook Actions is outbound-only by design — it turns WordPress events into outgoing HTTP requests. If you need WordPress to receive inbound webhooks, WP Webhooks or the WordPress REST API is the right tool."}},{"@type":"Question","name":"Is there a free WordPress webhook plugin?","acceptedAnswer":{"@type":"Answer","text":"Both have free plugins on WordPress.org. WP Webhooks free covers unlimited triggers and actions plus authentication and integrations. Webhook Actions free covers the entire delivery pipeline — queue, retry, delivery logs, replay, and bulk retry — across unlimited sites."}},{"@type":"Question","name":"How much do WP Webhooks Pro and Webhook Actions Pro cost?","acceptedAnswer":{"@type":"Answer","text":"WP Webhooks Pro is $149/year for 1 site, $249/year for 10 sites, and $499/year for 75 sites. Webhook Actions Pro is $249/$399/$649 for the same site counts, and includes a monthly pool of hosted AI credits — its in-admin AI builder runs without you supplying an AI provider key. WP Webhooks' AI integrations run on your own Gemini, ChatGPT, or OpenRouter key, which you pay for separately. Compare total cost with your AI usage in mind, not just the licence line."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wp-webhooks-alternative.png","caption":"FIG 01 — Webhook Actions outbound delivery pipeline","description":"A WordPress do_action fires the Webhook Actions listener, which builds a mapped payload and writes it to a persistent delivery queue that auto-upgrades from WP-Cron to Action Scheduler or system cron. The queue posts to the endpoint: a 2xx is logged delivered, a 5xx or 429 retries with exponential backoff, and a 4xx or 3xx is permanently failed but replayable.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "HubSpot WordPress Plugin vs Zapier vs Direct Webhooks" description: "The HubSpot WordPress plugin, Zapier and a direct webhook compared: what each one actually syncs, what it costs, and where the official plugin stops." url: "https://wpwebhooks.org/blog/wordpress-hubspot-integration-methods/" date: "2026-06-12" --- # HubSpot WordPress Plugin vs Zapier vs Direct Webhooks **TL;DR** - There are three common ways to connect WordPress to HubSpot: the **official HubSpot plugin**, **Zapier** as middleware, and a **direct webhook** to the HubSpot API. - The HubSpot plugin is best for marketing capture — forms, chat, and contacts — but it does not turn WooCommerce orders into deals or line items. - Zapier is the fastest no-code path and connects thousands of apps, but it bills per task and adds a third party to your data path. - A direct webhook is real-time, server-to-server, and has no per-task fee — the best fit for custom WooCommerce → HubSpot deal automation. / Overview ## What are the three ways to connect WordPress to HubSpot? Three approaches cover almost every WordPress-to-HubSpot project. The [official HubSpot plugin](https://wordpress.org/plugins/leadin/) embeds HubSpot's forms, chat, and CRM directly in WordPress¹. [Zapier](https://zapier.com/apps/hubspot/integrations/wordpress) sits between the two as no-code middleware². And a **direct webhook** sends WordPress events straight to the HubSpot API from your own server. They are not mutually exclusive — many sites run the HubSpot plugin for lead capture and a webhook for order automation. The question is which one owns each job. / Plugin ## When is the official HubSpot plugin enough? The official "HubSpot All-In-One Marketing" plugin (200,000+ installs, free) is the right starting point for marketing use cases: embed HubSpot forms and popups, add live chat, capture contacts, and sync them into the HubSpot CRM¹. If your goal is "collect leads on my WordPress site and have them appear in HubSpot," the plugin does it without code. Where it stops: it is a marketing-capture tool, not a general data-sync engine. It does not map a WooCommerce order to a HubSpot _deal_, attach _line items_, or fire on an arbitrary WordPress `do_action`. For e-commerce and custom-object automation, you need one of the next two approaches. / Zapier ## What does Zapier add, and what does it cost? Zapier connects WordPress (often via a form plugin or webhook trigger) to HubSpot and thousands of other apps, with branching and formatting in between — all no-code². It is the fastest way to wire "new Gravity Forms entry → create HubSpot contact" without touching the API. The trade-offs are structural. Zapier bills per **task** (each action step counts), so high-volume stores accrue cost as they grow³. Some triggers poll on an interval rather than fire instantly, adding latency. And every payload transits a third-party platform — a consideration for sensitive customer data and for teams that want fewer moving parts between WordPress and their CRM. FIG 01 — Direct webhook vs Zapier middleware / Webhooks ## How does a direct webhook integration work? A direct webhook sends the WordPress event straight to HubSpot's REST API from your server — no middleware. On `woocommerce_order_status_completed`, you POST to HubSpot's deals endpoint with the order mapped to deal properties; the response comes back to your site. With [Webhook Actions](https://wpwebhooks.org/wordpress-webhook-plugin/), you bind the WooCommerce hook in the admin UI, map the payload, and the plugin handles queued, retried delivery. This is the approach our [HubSpot × WooCommerce series](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) walks through end to end — [creating and updating deals on checkout](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/), batching line items, and syncing contacts. It is more setup than installing a plugin, but it gives you exact control over the HubSpot objects you create and keeps the data path entirely between your server and HubSpot. > Middleware is fastest to set up and a webhook is cheapest to run at scale — the crossover point is usually order volume and how much control you need over the HubSpot objects you create. / Comparison ## How do the three approaches compare? | Factor | HubSpot plugin | Zapier | Direct webhook | | --- | --- | --- | --- | | Setup effort | Lowest — install & connect | Low — no-code Zaps | Moderate — map the API | | Best for | Forms, chat, lead capture | Quick multi-app automations | WooCommerce deals, custom objects | | Timing | Real-time (forms) | Instant or polled per trigger | Real-time on any do\_action | | Ongoing cost | Free (plugin) | Per task — scales with volume | No per-task fee | | Data path | WP ↔ HubSpot | WP → Zapier → HubSpot | WP → HubSpot (direct) | | Reliability controls | Managed by HubSpot | Zapier auto-replay / history | Your queue: retry, logs, replay | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Reliability ## Which approach survives failures? HubSpot's API rate-limits and occasionally returns transient errors, so failure handling matters. Zapier maintains its own task history and can auto-replay failed steps. A direct webhook through Webhook Actions gives you an equivalent safety net on your own infrastructure: failed POSTs to HubSpot retry with exponential backoff, every attempt is logged with the request and response, and you can replay any delivery from the log. The [WooCommerce webhook setup guide](https://wpwebhooks.org/blog/woocommerce-webhooks-action-scheduler/) covers this for order events specifically. PHP — the raw POST a webhook replaces ``` // The direct API call, by hand. With Webhook Actions you bind // woocommerce_order_status_completed in the admin UI instead — // it builds this payload and queues + retries the delivery for you. add_action( 'woocommerce_order_status_completed', function( $order_id ) { $order = wc_get_order( $order_id ); wp_remote_post( 'https://api.hubapi.com/crm/v3/objects/deals', [ 'headers' => [ 'Authorization' => 'Bearer ' . $token ], 'body' => wp_json_encode( [ 'properties' => [ 'amount' => $order->get_total(), ] ] ), ] ); } ); ``` / Verdict ## Which should you pick? Run the **official HubSpot plugin** for marketing capture — forms, chat, and contact sync — on almost every site. Reach for **Zapier** when you need a quick automation across several apps and your volume is low enough that per-task pricing is comfortable. Choose a **direct webhook** when you need real-time WooCommerce → HubSpot deal automation, predictable cost at scale, and full control over the HubSpot objects you create. For most WooCommerce stores serious about HubSpot, the plugin handles lead capture and a webhook handles the commerce data — each doing the job it is best at. → The [Webhook Actions plugin](https://wpwebhooks.org/wordpress-webhook-plugin/) sends WooCommerce and any WordPress event straight to the HubSpot API with queued, retried delivery and full logs. See the [HubSpot × WooCommerce integration series](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) for the complete build. /Footnotes ¹ HubSpot plugin name, install count, and free features from the [HubSpot All-In-One Marketing plugin page](https://wordpress.org/plugins/leadin/) on WordPress.org (accessed June 2026). ² WordPress ↔ HubSpot connection via [Zapier](https://zapier.com/apps/hubspot/integrations/wordpress); see also HubSpot's [Zapier integration guide](https://knowledge.hubspot.com/integrations/how-to-use-zapier-and-hubspot). ³ Zapier bills per task; current plans at [zapier.com/pricing](https://zapier.com/pricing). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"HubSpot WordPress Plugin vs Zapier vs Direct Webhooks","description":"The HubSpot WordPress plugin, Zapier and a direct webhook compared: what each one actually syncs, what it costs, and where the official plugin stops.","datePublished":"2026-06-12","dateModified":"2026-06-12","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-hubspot-integration-methods/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["wordpress hubspot integration","hubspot wordpress plugin","connect hubspot to wordpress","woocommerce to hubspot","hubspot wordpress","wordpress to hubspot webhook"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"HubSpot WordPress Plugin vs Zapier vs Direct Webhooks","item":"https://wpwebhooks.org/blog/wordpress-hubspot-integration-methods/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is the best way to connect WordPress to HubSpot?","acceptedAnswer":{"@type":"Answer","text":"Use the official HubSpot plugin for marketing capture (forms, chat, contacts), Zapier for quick no-code automations across several apps, and a direct webhook for real-time WooCommerce-to-HubSpot deal automation with predictable cost. Many sites run the plugin plus a webhook together."}},{"@type":"Question","name":"Does the official HubSpot plugin sync WooCommerce orders to deals?","acceptedAnswer":{"@type":"Answer","text":"No. The official HubSpot All-In-One Marketing plugin is a marketing-capture tool for forms, chat, and contacts. It does not map a WooCommerce order to a HubSpot deal or attach line items — that requires Zapier or a direct webhook to the HubSpot API."}},{"@type":"Question","name":"Is Zapier or a webhook better for HubSpot integration?","acceptedAnswer":{"@type":"Answer","text":"Zapier is faster to set up and connects thousands of apps, but it bills per task and routes data through a third party. A direct webhook is real-time, server-to-server, has no per-task fee, and gives you full control over the HubSpot objects you create — better for high-volume or custom WooCommerce automation."}},{"@type":"Question","name":"Can I send WooCommerce data to HubSpot without Zapier?","acceptedAnswer":{"@type":"Answer","text":"Yes. A plugin like Webhook Actions binds a WooCommerce hook such as woocommerce_order_status_completed and POSTs the mapped order straight to the HubSpot deals API, with queued and retried delivery — no middleware required."}},{"@type":"Question","name":"Is the HubSpot WordPress plugin free?","acceptedAnswer":{"@type":"Answer","text":"Yes. The official HubSpot All-In-One Marketing plugin is free with 200,000+ active installs, covering CRM, forms, popups, live chat, email, and analytics. Advanced HubSpot features require a paid HubSpot plan."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-hubspot-integration-methods.png","caption":"FIG 01 — Direct webhook vs Zapier middleware","description":"A direct webhook sends a WooCommerce order server-to-server through Webhook Actions straight to the HubSpot API. The Zapier route inserts a polling trigger and a paid Zapier task between WooCommerce and HubSpot, putting a third party in the data path.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress External Cron: Services, Setup, and Free Options" description: "Fix unreliable WP-Cron with a free external cron service. Covers cron-job.org, EasyCron, UptimeRobot setup, DISABLE_WP_CRON config, and the zero-config Webhook Actions option." url: "https://wpwebhooks.org/blog/wordpress-external-cron-services/" date: "2026-06-05" --- # WordPress External Cron: Services, Setup, and Free Options **TL;DR** - WP-Cron fires on page load, not on a real schedule — low-traffic sites can miss events for hours - Fix it by pinging `/wp-cron.php?doing_wp_cron` every minute from an external service - Free options: [cron-job.org](https://cron-job.org/en/), [UptimeRobot](https://uptimerobot.com), [EasyCron](https://www.easycron.com) - Always add `define('DISABLE_WP_CRON', true)` to `wp-config.php` after setting up an external cron - Webhook Actions Pro includes a managed zero-config external cron — details in the content below / Why WP-Cron fails ## Why does WP-Cron fail on low-traffic sites? WordPress ships with a built-in scheduling system called [WP-Cron](https://developer.wordpress.org/plugins/cron/). Unlike a real cron daemon, WP-Cron does not run on a timer. It runs on page load: every time a visitor (or bot) requests a WordPress page, the site checks whether any scheduled jobs are due and runs them inline. This works acceptably on high-traffic sites because there is always a page load happening. On low-traffic sites — staging environments, small business sites, early-stage products — large gaps between page loads mean scheduled jobs pile up unprocessed. The practical consequences: a scheduled email digest fires at 3 AM only if someone visits the site between 2:59 AM and 3:01 AM. A webhook queue runner that should fire every minute runs only as often as traffic arrives. A plugin that expires licenses daily may not run for 18 hours on a quiet Sunday. External cron replaces the page-load trigger with a real scheduled HTTP ping — a service you control, running on its own schedule, regardless of site traffic. FIG 01 — WP-Cron (page-load) vs external cron service / The cron URL ## What URL do you ping for WordPress external cron? The standard URL for triggering all WordPress scheduled jobs is: `https://yourdomain.com/wp-cron.php?doing_wp_cron` Replace `yourdomain.com` with your actual domain. The `?doing_wp_cron` query parameter tells WordPress this is an intentional external trigger rather than a side-effect of a page load. The endpoint always returns HTTP 200 — even when no jobs were due — so status monitoring alone is not a reliable way to confirm cron ran. See the _verification_ section below for a better approach. If your site uses HTTPS (it should), always use the HTTPS URL. Most external cron services follow redirects, but some do not — use the canonical HTTPS URL directly to avoid a redirect on every ping. / cron-job.org setup ## How do you set up WordPress external cron with **cron-job.org**? [cron-job.org](https://cron-job.org/en/) is a free HTTP cron service with no rate limiting on 1-minute intervals, email failure notifications, and an execution log for each job. No payment is required for standard use. 1. **Create an account** at [cron-job.org](https://cron-job.org/en/) — free, email verification required. 2. **Add a new cron job.** In the dashboard click _Create cronjob_. Set the URL to `https://yourdomain.com/wp-cron.php?doing_wp_cron` and the request method to _GET_. 3. **Set the schedule.** Choose _Every 1 minute_ under the schedule picker. For low-traffic sites, every minute is the right starting point — drop to every 5 minutes only if you have no sub-5-minute scheduled tasks. 4. **Enable failure notifications.** Under _Notifications_, enable email alerts on failure. This is your early warning when the WordPress site is down or wp-cron.php is returning non-200 responses. 5. **Add `DISABLE_WP_CRON` to wp-config.php.** See the next section — this step is required to prevent page loads from triggering cron in parallel. ![Neon cyberpunk illustration for WordPress external cron services, showing scheduled jobs triggering webhook deliveries.](https://wpwebhooks.org/blog/wordpress-external-cron-services/og_image.jpg) / Other free services ## What other free external cron services work with WordPress? **[UptimeRobot](https://uptimerobot.com/)** (free tier: up to 50 monitors, 5-minute minimum interval) is primarily an uptime monitor but doubles as a cron trigger — add a monitor of type HTTP(s) pointing at your wp-cron.php URL and set the check interval to 5 minutes. The free plan does not support 1-minute intervals, but for most sites a 5-minute cron window is acceptable. You also get uptime history as a bonus. **[EasyCron](https://www.easycron.com)** offers a free tier with one cron job running every 20 minutes — suitable only for low-frequency tasks. Paid plans unlock 1-minute intervals. For single-site WordPress with budget constraints, EasyCron's free tier is usable but not recommended for sites with webhook queues that need frequent flushing. **[Healthchecks.io](https://healthchecks.io)** works differently: it is a dead man's switch, not an active pinger. You configure a check with an expected ping interval, then your cron job pings the Healthchecks URL to signal it ran. Healthchecks alerts you when it _stops_ receiving pings. For WordPress external cron, you would use a system cron entry or another service as the actual trigger, and Healthchecks for monitoring. It is free for up to 20 checks. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / DISABLE\_WP\_CRON ## How do you configure **DISABLE\_WP\_CRON** correctly? After setting up an external cron service, add this constant to `wp-config.php` _before_ the `/* That's all, stop editing! */` line: wp-config.php — disable page-load cron trigger ``` // Add before "That's all, stop editing!" define( 'DISABLE_WP_CRON', true ); ``` **Add this only after** your external cron service is active and confirmed running. If you disable WP-Cron before the external service is live, no scheduled jobs will run at all — including transient expiration, update checks, and your own webhook queues. Without `DISABLE_WP_CRON`, every page load attempts to spawn a cron worker via a non-blocking loopback request to `wp-cron.php`. This means your external service and page-load traffic are both trying to claim and run the same due jobs. In most cases WordPress's lock mechanism prevents double-execution, but the wasted HTTP round-trips add overhead to every page load. Disabling the page-load trigger makes cron timing fully deterministic. / REST endpoint vs wp-cron.php ## When should you use a plugin REST endpoint instead of wp-cron.php? Pinging `wp-cron.php` runs all due jobs across every plugin on the site. For most WordPress installations this is fine. But there are cases where you want to run only one plugin's queue, with tighter control over batch size, or at a shorter interval than 60 seconds. Webhook Actions Pro exposes a dedicated cron endpoint: `/wp-json/fswa/v1/cron/process?token=`. Pinging this URL processes only the Webhook Actions delivery queue — not email campaigns, not license checks, not anything from other plugins. You can configure the batch size (1–100 items per ping) and run it as frequently as every 20 seconds from an external service. The WP-Cron endpoint has a minimum interval of 60 seconds because WordPress's own lock mechanism throttles faster pings. See the [External Cron feature docs](https://wpwebhooks.org/docs/external-cron/) for configuration details. | Approach | Interval | Scope | Setup | | --- | --- | --- | --- | | wp-cron.php (page-load, default) | On traffic only | All plugins | None — built in | | External service → wp-cron.php | Min ~1 min | All plugins | Simple, free | | External service → plugin REST endpoint | Min 20s | One plugin only | Fine-grained, Pro | / Verification ## How do you verify your external cron is actually running? HTTP 200 from wp-cron.php only means WordPress responded — it does not confirm any jobs ran. Two reliable verification methods: **Check a plugin's queue log.** Webhook Actions shows per-delivery timestamps in its Logs screen. After enabling external cron, trigger a test delivery and confirm the timestamp matches the expected interval. If the delivery log shows deliveries clustered at irregular intervals, page-load cron is still running alongside your external trigger — check whether `DISABLE_WP_CRON` is set correctly. **Use WP-CLI from the server.** If you have server access, [`wp cron event run --due-now`](https://developer.wordpress.org/cli/commands/cron/event/run/) manually fires all due events and prints which hooks executed. Run it after your external cron fires and confirm the expected hooks appear in the output. / Zero-config option ## What is the zero-config alternative to managing external cron yourself? If you are running Webhook Actions Pro, you do not need to configure cron-job.org, UptimeRobot, or any external service manually. The [External Cron feature](https://wpwebhooks.org/docs/external-cron/) provisions a managed [Uptime Kuma](https://uptime.kuma.pet/) heartbeat monitor automatically when you activate your Pro license. It handles `DISABLE_WP_CRON` configuration, sets the ping interval (down to 20 seconds in plugin-endpoint mode), and shows a live heartbeat chart with monitor status directly in the Webhook Actions admin screen. For teams already managing external cron manually — via a server crontab, hosting panel, or one of the services above — the external cron approach in this article is completely valid and costs nothing. The Webhook Actions managed option is for teams who want reliability without any server or service configuration overhead. See the [WP-Cron developer reference](https://wpwebhooks.org/blog/cron-job-for-wordpress/) for the full `wp_schedule_event` and WP-CLI cron command reference if you need finer-grained control. > External cron is not a plugin feature — it is a fundamental requirement for any WordPress site that relies on scheduled jobs running at predictable times. /Footnotes ¹ [WordPress Cron (WP-Cron) — Plugin Developer Handbook](https://developer.wordpress.org/plugins/cron/). ² [wp cron event run — WP-CLI command reference](https://developer.wordpress.org/cli/commands/cron/event/run/). ³ [cron-job.org FAQ](https://cron-job.org/en/faq/) — free tier limits and interval options. ⁴ [UptimeRobot](https://uptimerobot.com/) — HTTP monitor service with free tier supporting 5-minute check intervals. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress External Cron: Services, Setup, and Free Options","description":"Fix unreliable WP-Cron with a free external cron service. Covers cron-job.org, EasyCron, UptimeRobot setup, DISABLE_WP_CRON config, and the zero-config Webhook Actions option.","datePublished":"2026-06-05","dateModified":"2026-06-05","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-external-cron-services/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-external-cron-services/og_image.jpg","width":1200,"height":630,"caption":"Neon cyberpunk illustration for WordPress external cron services, showing scheduled jobs triggering webhook deliveries."},"keywords":["wordpress external cron","wordpress cron service","cron job org wordpress","disable wp cron","external cron wordpress","wp cron replacement","wordpress cron not running","easycron wordpress"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress External Cron: Services, Setup, and Free Options","item":"https://wpwebhooks.org/blog/wordpress-external-cron-services/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is the best free external cron service for WordPress?","acceptedAnswer":{"@type":"Answer","text":"cron-job.org is the most widely used free option — it supports custom intervals as short as one minute, HTTP monitoring, and email alerts with no account payment required for basic use. UptimeRobot is a free alternative that doubles as a site uptime monitor. EasyCron offers a free tier for low-frequency jobs."}},{"@type":"Question","name":"What URL should an external cron service ping for WordPress?","acceptedAnswer":{"@type":"Answer","text":"The standard URL is https://yourdomain.com/wp-cron.php?doing_wp_cron — a GET request every one to five minutes. Before configuring the service, add define(\"DISABLE_WP_CRON\", true) to wp-config.php so page loads no longer trigger cron in parallel."}},{"@type":"Question","name":"What is DISABLE_WP_CRON and why should you enable it with external cron?","acceptedAnswer":{"@type":"Answer","text":"DISABLE_WP_CRON is a wp-config.php constant that prevents WordPress from triggering its cron system on every page load. Without it, page loads compete with your external cron pings, causing duplicate cron runs. When using an external service, DISABLE_WP_CRON makes cron timing fully predictable."}},{"@type":"Question","name":"What is the difference between pinging wp-cron.php and a plugin REST endpoint?","acceptedAnswer":{"@type":"Answer","text":"Pinging wp-cron.php runs all pending WordPress cron jobs from every plugin on the site. A plugin REST endpoint like the Webhook Actions /wp-json/fswa/v1/cron/process endpoint runs only that plugin's delivery queue, with fine-grained control over batch size and shorter minimum intervals (20s vs 60s for wp-cron.php)."}},{"@type":"Question","name":"Is the External Cron feature in Webhook Actions available on the free plan?","acceptedAnswer":{"@type":"Answer","text":"No. External Cron is a Pro feature in Webhook Actions. It auto-provisions a managed Uptime Kuma heartbeat monitor on Pro license activation, handles DISABLE_WP_CRON configuration automatically, and provides a live heartbeat chart and monitor status in wp-admin. Free users can configure any of the manual services described in this article."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-external-cron-services.png","caption":"FIG 01 — WP-Cron (page-load) vs external cron service","description":"Default WP-Cron only runs when a visitor request spawns it, so jobs stall on low-traffic sites. An external cron service such as cron-job.org or UptimeRobot sends a scheduled HTTP GET to wp-cron.php every minute, so WordPress runs all due jobs reliably regardless of traffic.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "gform_after_update_entry Hook: Gravity Forms Webhook" description: "How the gform_after_update_entry hook fires on Gravity Forms entry edits — its parameters, original vs updated values, and sending a webhook on update." url: "https://wpwebhooks.org/blog/gravity-forms-gform-after-update-entry-webhook/" date: "2026-06-02" --- # gform_after_update_entry Hook: Gravity Forms Webhook **TL;DR** - `gform_after_update_entry` fires after an existing entry is edited in wp-admin or updated via `GFAPI::update_entry()` - Three parameters: `$form`, `$entry_id`, `$original_entry` (pre-update state, GF 2.4+) - Retrieve the current entry with `GFAPI::get_entry($entry_id)` — the hook does not pass the updated entry directly - Webhook Actions handles update-triggered delivery natively — no custom PHP needed / What is gform\_after\_update\_entry ## What is **gform\_after\_update\_entry** and when does it fire? `gform_after_update_entry` is a [Gravity Forms action hook](https://docs.gravityforms.com/gform_after_update_entry/) that fires immediately after an existing form entry has been saved with new values. It covers three update paths: editing an entry in the wp-admin entry editor, updating via the `GFAPI::update_entry()` method from custom code, and updates triggered by Gravity Forms add-ons (such as User Registration or Feed add-ons that update entry data post-submission). What it does _not_ cover: new submissions. An entry created by a visitor submitting a form triggers `gform_after_submission`, not `gform_after_update_entry`. The two hooks are complementary — one fires on create, the other on update. Use both if you need to keep a downstream system fully in sync with all Gravity Forms entry state changes. Both sit within the wider set of lifecycle hooks covered in the [Gravity Forms hooks reference](https://wpwebhooks.org/blog/gravity-forms-hooks-reference/). FIG 01 — Gravity Forms entry lifecycle and webhook hooks / Submission vs update ## How does **gform\_after\_update\_entry** differ from **gform\_after\_submission**? `gform_after_submission` fires once, when a visitor submits the form and a new entry row is created. `gform_after_update_entry` fires every time an existing entry is subsequently modified — which means it can fire many times for the same entry ID over its lifetime. The distinction matters for downstream integrations. A CRM contact created from `gform_after_submission` might need to be updated when an entry is later edited: the order status changes, a support agent adds a note, or an admin corrects a typo in the email field. Those updates arrive via `gform_after_update_entry`. Without a webhook on this hook, your CRM retains the original submission data even when the Gravity Forms entry has changed. A third hook, `gform_post_update_entry`, is an alias used by GFAPI internals — prefer `gform_after_update_entry` for external integrations, as it is the [documented public hook](https://docs.gravityforms.com/gform_after_update_entry/). / Parameters ## What parameters does **gform\_after\_update\_entry** pass? The hook passes three arguments to your callback. Register with `add_action( 'gform_after_update_entry', $callback, 10, 3 )` to receive all three: - **$form** — the full Gravity Forms form array, including field definitions, confirmations, notifications, and settings. Use `$form['id']` for the form ID and `$form['fields']` to iterate over field metadata. - **$entry\_id** — the integer ID of the entry that was just updated. The entry itself is not passed directly; retrieve it with `GFAPI::get_entry($entry_id)`. - **$original\_entry** — the entry array as it existed _before_ the update, available since Gravity Forms 2.4. Use this to build diff payloads or to skip the webhook when only irrelevant fields changed. PHP — hook registration with all three parameters ``` add_action( 'gform_after_update_entry', function( $form, $entry_id, $original_entry ) { // $form — full form array // $entry_id — integer entry ID // $original_entry — entry values before the update }, 10, 3 ); // ← priority, accepted_args — must pass 3 ``` / Reading updated values ## How do you read the original and updated entry values? The hook gives you the pre-update state via `$original_entry`. To read the post-update state, call `GFAPI::get_entry($entry_id)`. Field values are keyed by field ID as a string — `$entry['1']` for field ID 1, `$entry['2']` for field ID 2. Use `$form['fields']` to find field IDs and labels. PHP — reading original vs updated entry values ``` add_action( 'gform_after_update_entry', function( $form, $entry_id, $original_entry ) { // Retrieve the current (updated) entry $updated_entry = GFAPI::get_entry( $entry_id ); if ( is_wp_error( $updated_entry ) ) { return; } // Compare a specific field (field ID 3 = status in this example) $old_status = $original_entry['3'] ?? ''; $new_status = $updated_entry['3'] ?? ''; // Skip webhook if the status field did not change if ( $old_status === $new_status ) { return; } $payload = [ 'form_id' => $form['id'], 'entry_id' => $entry_id, 'old_status' => $old_status, 'new_status' => $new_status, 'entry' => $updated_entry, ]; wp_remote_post( 'https://your-endpoint.com/webhook', [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $payload ), 'timeout' => 5, 'data_format' => 'body', ] ); }, 10, 3 ); ``` / Use cases ## What are the common use cases for entry-update webhooks? Entry-update webhooks enable a class of integrations that new-submission webhooks cannot cover on their own. The most frequent patterns in production: - **CRM status sync.** A sales team updates the entry status field (e.g. "Qualified" → "Closed") in wp-admin. The update webhook fires and pushes the new deal stage to HubSpot, Salesforce, or Pipedrive without the team needing access to the CRM directly. - **Order or ticket lifecycle tracking.** Service businesses use Gravity Forms as a lightweight order management tool. Status changes to "In Progress", "Awaiting Parts", or "Completed" trigger webhooks that update job boards or send notifications to n8n workflows. - **Data correction propagation.** When an admin corrects a typo — wrong email, misspelled name, incorrect address — the update webhook propagates the fix to downstream systems without a manual re-sync. - **Add-on triggered updates.** Gravity Forms add-ons (User Registration, Stripe, GravityView) can update entry values programmatically via GFAPI. `gform_after_update_entry` fires on these programmatic updates too, enabling event-driven automation even for machine-generated changes. / Webhook Actions ## How does Webhook Actions handle update-triggered delivery? The [Webhook Actions plugin](https://wpwebhooks.org/wordpress-webhook-plugin/) listens for Gravity Forms entry updates as a native trigger. Select "Gravity Forms — Entry Updated" from the trigger picker, choose the form, and configure the webhook endpoint — no PHP required. The plugin dispatches deliveries asynchronously: the update response in wp-admin returns immediately while delivery happens in a background queue cycle. Delivery logs track every attempt: HTTP status code, response body, timestamp, and retry count. Failed deliveries retry automatically with exponential backoff (1 min → 2 min → 4 min → 8 min, capped at 1 hour, default 5 attempts). You can replay any failed delivery from the Logs screen. > Entry-update webhooks close the sync gap that new-submission webhooks leave open — they're the difference between a CRM snapshot and a CRM mirror. | Capability | Raw gform\_after\_update\_entry + wp\_remote\_post | Webhook Actions | | --- | --- | --- | | Setup | Custom PHP — hook registration, payload construction | Admin UI trigger picker — no code | | Delivery | Synchronous — blocks admin save response | Async queue — admin save returns immediately | | Retry | None | Exponential backoff, up to 5 attempts | | Logs | None — failures are invisible | Per-attempt log with HTTP status and response body | | Replay | Not possible without custom tooling | One-click replay from the Logs screen | | Deduplication | Manual — requires field-diff logic in PHP | Queue deduplication handled by the plugin | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Deduplication ## How do you prevent duplicate webhooks on rapid successive updates? Rapid successive updates are a real pattern: an automated workflow may update the same entry four times in a loop, or two admin users may save the same entry within seconds of each other. Without deduplication, your downstream system receives multiple payloads for a single logical change. The field-diff approach shown in the code example above — comparing `$original_entry['3']` to `$updated_entry['3']` and returning early if unchanged — is the most common manual solution. Only send the webhook when a field that matters to your integration actually changed. For updates that involve multiple fields, compare a hash of the relevant subset: `md5(serialize(array_intersect_key($updated_entry, $watched_fields)))` against the same hash from `$original_entry`. At the delivery level, include the entry ID and a hash of the payload in a transient with a short TTL (30–60 seconds). If an identical delivery arrives within the TTL, skip it. This catches duplicate hook invocations that would produce identical payloads even with field diffing. The [gform\_after\_submission webhook guide](https://wpwebhooks.org/blog/gravity-forms-gform-after-submission-webhook/) covers additional Gravity Forms webhook patterns that complement the update flow. If you offload the dispatch as a background action, Action Scheduler has its own idempotency tools — see [preventing duplicate scheduled actions](https://wpwebhooks.org/blog/action-scheduler-prevent-duplicate-actions/). /Footnotes ¹ [gform\_after\_update\_entry — Gravity Forms developer docs](https://docs.gravityforms.com/gform_after_update_entry/). ² [GFAPI reference](https://docs.gravityforms.com/api-functions/) — `get_entry`, `update_entry`, and all public API methods. ³ [Action Scheduler](https://actionscheduler.org/) — persistent background job queue used by Webhook Actions for async delivery. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"gform_after_update_entry Hook: Gravity Forms Webhook","description":"How the gform_after_update_entry hook fires on Gravity Forms entry edits — its parameters, original vs updated values, and sending a webhook on update.","datePublished":"2026-06-02","dateModified":"2026-06-02","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/gravity-forms-gform-after-update-entry-webhook/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["gform after update entry","gravity forms entry update webhook","gform after update entry hook documentation","gravity forms webhook on update","gravity forms entry update hook","gform after update entry webhook"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"gform_after_update_entry Hook: Gravity Forms Webhook","item":"https://wpwebhooks.org/blog/gravity-forms-gform-after-update-entry-webhook/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is the difference between gform_after_update_entry and gform_after_submission?","acceptedAnswer":{"@type":"Answer","text":"gform_after_submission fires when a new Gravity Forms entry is created via a form submit. gform_after_update_entry fires when an existing entry is updated — from the wp-admin entry editor or via GFAPI::update_entry(). Use gform_after_update_entry to sync CRM records when entry data is corrected after the original submission."}},{"@type":"Question","name":"What parameters does gform_after_update_entry pass?","acceptedAnswer":{"@type":"Answer","text":"Three parameters: $form (the full form array), $entry_id (integer ID of the updated entry), and $original_entry (the entry array as it was before the update, available since Gravity Forms 2.4). To get the current entry after the update, call GFAPI::get_entry($entry_id)."}},{"@type":"Question","name":"How do you access the updated field values inside gform_after_update_entry?","acceptedAnswer":{"@type":"Answer","text":"Call GFAPI::get_entry($entry_id) to retrieve the current entry. Field values are keyed by field ID as a string (e.g. $entry[\"1\"] for field ID 1). Use $form[\"fields\"] to find field IDs. The $original_entry parameter gives you the pre-update state for building a diff payload."}},{"@type":"Question","name":"Can gform_after_update_entry fire multiple times on rapid updates?","acceptedAnswer":{"@type":"Answer","text":"Yes. If a workflow updates the same entry programmatically in a loop, or if two users edit the same entry in quick succession, the hook can fire multiple times within seconds. To prevent duplicate webhooks, compare key fields between $original_entry and GFAPI::get_entry($entry_id), or use a plugin that handles deduplication at the queue level."}},{"@type":"Question","name":"Does Webhook Actions trigger on Gravity Forms entry updates?","acceptedAnswer":{"@type":"Answer","text":"Yes. Webhook Actions listens for Gravity Forms entry updates as a native trigger type. When an entry changes, a delivery is queued with the current field values and entry metadata. Configure the trigger in the plugin admin and select the form — no custom PHP required."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/gravity-forms-gform-after-update-entry-webhook.png","caption":"FIG 01 — Gravity Forms entry lifecycle and webhook hooks","description":"A visitor submission creates an entry and fires gform_after_submission, the point for a new-entry webhook. Later, when an admin edits the entry or GFAPI::update_entry runs, the saved entry fires gform_after_update_entry with the form, entry id, and original entry — the point for an update webhook sent downstream.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "wpcf7_mail_sent Hook: Contact Form 7 to Webhook Guide" description: "How wpcf7_mail_sent fires, what get_posted_data() returns, and the reliable way to send Contact Form 7 submissions to any webhook endpoint." url: "https://wpwebhooks.org/blog/wordpress-wpcf7-mail-sent-hook/" date: "2026-05-30" --- # wpcf7_mail_sent Hook: Contact Form 7 to Webhook Guide **TL;DR** - `wpcf7_mail_sent` fires after CF7 successfully sends its notification email — use it when you need confirmed delivery before dispatching a webhook - Access field values via `WPCF7_Submission::get_instance()->get_posted_data()` - Calling `wp_remote_post` inside the hook is synchronous — it blocks PHP until your endpoint responds - Webhook Actions ships a built-in CF7 integration with async queue, retry, and delivery logs — no custom PHP needed / What is wpcf7\_mail\_sent ## What is **wpcf7\_mail\_sent** and when does it fire? `wpcf7_mail_sent` is a WordPress action hook provided by [Contact Form 7](https://contactform7.com/) (CF7) — with over 10 million active installs¹, the most widely deployed form plugin in the WordPress ecosystem. The hook fires at the end of CF7's form processing pipeline, specifically after the plugin has successfully delivered its configured notification email. The practical implication: if you hook into `wpcf7_mail_sent`, you know the form passed validation, cleared spam checks, and that CF7's own mailer completed without error. This makes it the right attachment point for downstream automations — CRM entries, Slack notifications, and webhook dispatches — where you want confirmation the form fully succeeded before triggering side effects. FIG 01 — CF7 form submission and hook lifecycle / Before vs After ## How does **wpcf7\_mail\_sent** differ from **wpcf7\_before\_send\_mail**? Contact Form 7 exposes two hooks around its mail-sending step. `wpcf7_before_send_mail` fires immediately before CF7 attempts to send the email and receives a reference to the `$abort` flag — setting it to `true` prevents both the email send and any `wpcf7_mail_sent` callback. This makes `wpcf7_before_send_mail` the right hook for altering payload data or implementing custom spam filtering. `wpcf7_mail_sent` fires only after the mail send completes successfully. You cannot abort or modify the submission at this point — the hook is purely for reacting to a confirmed submission. Use `wpcf7_mail_sent` when your downstream system should only receive data that CF7 itself treated as fully processed. A third hook, `wpcf7_submit`, fires after validation but before the mail step. If you need to capture every validated submission regardless of email delivery status, `wpcf7_submit` is the hook to use — but for webhook dispatch, the later `wpcf7_mail_sent` is usually preferable because it filters out aborted or failed submissions automatically. / Hook parameters ## What parameters does **wpcf7\_mail\_sent** pass to your callback? The hook passes a single argument: `$contact_form`, an instance of `WPCF7_ContactForm`. This object exposes the form configuration — its ID, title, and properties — but not the submitted field values. Submitted values live on the _submission_ object, which is accessed separately. PHP — hook registration ``` add_action( 'wpcf7_mail_sent', function( $contact_form ) { // $contact_form is WPCF7_ContactForm $form_id = $contact_form->id(); // integer $form_name = $contact_form->title(); // string — the form name from CF7 editor // Submitted values are NOT on $contact_form — see next section } ); ``` / Accessing field values ## How do you access submitted field values inside **wpcf7\_mail\_sent**? Submitted data is available on the `WPCF7_Submission` singleton. Call `WPCF7_Submission::get_instance()` — this returns the submission object for the current request, or `null` outside a CF7 processing context. Always null-check before proceeding. The `get_posted_data()` method returns an associative array keyed by the `name` attributes set in the CF7 form editor (the values inside square brackets in CF7 shortcodes, e.g. `[text your-name]`). All values are run through CF7's sanitization pipeline before being available here. PHP — reading submitted field values ``` add_action( 'wpcf7_mail_sent', function( $contact_form ) { $submission = WPCF7_Submission::get_instance(); if ( ! $submission ) { return; } $data = $submission->get_posted_data(); // $data is an associative array: // [ 'your-name' => 'Jane Smith', 'your-email' => 'jane@example.com', ... ] $payload = [ 'form_id' => $contact_form->id(), 'form_name' => $contact_form->title(), 'name' => $data['your-name'] ?? '', 'email' => $data['your-email'] ?? '', 'message' => $data['your-message'] ?? '', ]; } ); ``` / Webhook dispatch ## How do you dispatch a webhook from **wpcf7\_mail\_sent**? Once you have the payload array, pass it to `wp_remote_post` with a JSON body. The implementation is a direct HTTP call: serialise the payload with `wp_json_encode`, set `Content-Type: application/json`, and send to your endpoint URL. PHP — wp\_remote\_post dispatch inside wpcf7\_mail\_sent ``` add_action( 'wpcf7_mail_sent', function( $contact_form ) { $submission = WPCF7_Submission::get_instance(); if ( ! $submission ) { return; } $data = $submission->get_posted_data(); $payload = [ 'form_id' => $contact_form->id(), 'name' => $data['your-name'] ?? '', 'email' => $data['your-email'] ?? '', 'message' => $data['your-message'] ?? '', ]; wp_remote_post( 'https://your-endpoint.com/webhook', [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $payload ), 'timeout' => 5, 'blocking' => true, // ← PHP waits here 'data_format' => 'body', ] ); } ); ``` / Reliability risks ## What are the reliability risks of synchronous dispatch inside a hook? The code above works — but it is synchronous. When PHP executes `wp_remote_post` with `'blocking' => true`, the process stalls until your webhook endpoint sends a response or the timeout elapses (5 seconds in the example). During that window, the user's browser is waiting for the CF7 success response, the PHP-FPM worker is occupied, and any slow or unavailable endpoint directly degrades your form's perceived performance. There is also no retry. If your endpoint returns a 5xx error or times out, the delivery is silently lost — no log entry, no re-attempt, no alert. On high-traffic forms this adds up quickly: a downstream endpoint that experiences 2% transient failures across 500 daily submissions means 10 missed integrations per day with no visibility. > Calling `wp_remote_post` synchronously inside a form hook means every visitor who submits your form waits for your webhook endpoint — not just for WordPress. / Webhook Actions ## How does Webhook Actions handle CF7 webhook delivery without custom code? The [Webhook Actions plugin](https://wpwebhooks.org/wordpress-webhook-plugin/) includes a built-in Contact Form 7 integration. When CF7 is active alongside Webhook Actions, CF7 form submissions appear as native trigger types in the plugin's admin UI. You select the form, map fields to your payload using the visual mapper, and configure the endpoint URL — no PHP code required. Delivery is handled asynchronously: Webhook Actions enqueues each submission via [Action Scheduler](https://actionscheduler.org/) when available, which means the form response returns immediately and the HTTP dispatch happens in the next queue cycle — typically within seconds but always outside the visitor's request. Failed deliveries retry automatically with exponential backoff (1 min → 2 min → 4 min → 8 min, capped at 1 hour, default 5 attempts) and are logged with the full request body and HTTP response for inspection in wp-admin. | Capability | Raw wpcf7\_mail\_sent + wp\_remote\_post | Webhook Actions (built-in CF7) | | --- | --- | --- | | Setup | Custom PHP in functions.php or plugin | Visual admin UI — no code | | Delivery | Synchronous — blocks form response | Async via Action Scheduler queue | | Retry | None — one attempt only | Exponential backoff, 5 attempts | | Delivery log | None — failures are silent | Per-attempt log with request and response in wp-admin | | Field mapping | Manual array construction in PHP | GUI mapper — rename, restructure, exclude fields | | Multiple webhooks | One add\_action per endpoint | Unlimited webhooks per form trigger | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Testing and monitoring ## How do you test and monitor CF7 webhook delivery in production? The quickest way to validate the raw `wpcf7_mail_sent` approach is to point the endpoint URL at a request inspector like [webhook.site](https://webhook.site) — a temporary URL that logs every inbound request with full headers and body. Submit the form, then inspect the payload on webhook.site to confirm field values and structure before switching to the real destination. For production monitoring without a plugin, [`is_wp_error`](https://developer.wordpress.org/reference/functions/is_wp_error/) on the `wp_remote_post` return value surfaces transport errors (timeouts, DNS failures), and `wp_remote_retrieve_response_code` gives the HTTP status. Log both to `error_log` or a custom table for visibility. With Webhook Actions, delivery logs are built-in: every attempt records the timestamp, HTTP status code, full response body, and retry count — visible from the Webhook Actions → Logs screen. The [Contact Form 7 to Webhook overview](https://wpwebhooks.org/blog/contact-form-7-webhook/) covers the full Webhook Actions setup flow including test delivery from the plugin admin. Once the hook fires reliably, the remaining work is destination-specific — the receiving API decides the payload shape, not Contact Form 7. Two worked examples: [sending entries to an Airtable base](https://wpwebhooks.org/blog/contact-form-7-to-airtable/), where every key must already exist as a field, and [creating a Notion page per entry](https://wpwebhooks.org/blog/contact-form-7-to-notion/), where each value has to be wrapped in its declared property type. /Footnotes ¹ Contact Form 7 active install count from the [WordPress.org plugin directory](https://wordpress.org/plugins/contact-form-7/). ² [Contact Form 7 documentation](https://contactform7.com/docs/) — official hook and API reference. ³ [Action Scheduler usage guide](https://actionscheduler.org/usage/) — async job scheduling for WordPress. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"wpcf7_mail_sent Hook: Contact Form 7 to Webhook Guide","description":"How wpcf7_mail_sent fires, what get_posted_data() returns, and the reliable way to send Contact Form 7 submissions to any webhook endpoint.","datePublished":"2026-05-30","dateModified":"2026-05-30","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-wpcf7-mail-sent-hook/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["wpcf7 mail sent","contact form 7 webhook","cf7 webhook","contact form 7 to webhook plugin","cf7 form submission webhook","contact form 7 webhooks plugin","wpcf7 mail sent hook"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"wpcf7_mail_sent Hook: Contact Form 7 to Webhook Guide","item":"https://wpwebhooks.org/blog/wordpress-wpcf7-mail-sent-hook/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is the difference between wpcf7_mail_sent and wpcf7_before_send_mail?","acceptedAnswer":{"@type":"Answer","text":"wpcf7_before_send_mail fires before CF7 sends its notification email and can abort the send by setting $abort to true. wpcf7_mail_sent fires after the email has been sent successfully. For webhook dispatch where you want confirmation the form was fully processed, wpcf7_mail_sent is the right hook."}},{"@type":"Question","name":"How do you access Contact Form 7 field values inside wpcf7_mail_sent?","acceptedAnswer":{"@type":"Answer","text":"Call WPCF7_Submission::get_instance() to retrieve the active submission object, then call $submission->get_posted_data() to get an associative array of field names to submitted values. Field names match the name attributes in the CF7 form editor."}},{"@type":"Question","name":"Does wpcf7_mail_sent fire if the CF7 email fails to send?","acceptedAnswer":{"@type":"Answer","text":"No. wpcf7_mail_sent fires only after a successful mail delivery. If you need to catch all valid submissions regardless of email status, use wpcf7_submit, which fires after validation passes but before the mail send attempt."}},{"@type":"Question","name":"Is it safe to call wp_remote_post inside wpcf7_mail_sent?","acceptedAnswer":{"@type":"Answer","text":"It works but blocks PHP execution until the remote endpoint responds. On a slow or unavailable endpoint this delays the success response seen by the visitor. The safer pattern is to enqueue the delivery via Action Scheduler or use Webhook Actions, which handles async delivery with automatic retry."}},{"@type":"Question","name":"Does Webhook Actions require a separate CF7 webhook plugin?","acceptedAnswer":{"@type":"Answer","text":"No. Webhook Actions has a built-in CF7 integration. When Contact Form 7 is active, submissions appear as triggers in the webhook configuration screen with structured, pre-mapped field payloads — no additional plugin or custom PHP code needed."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-wpcf7-mail-sent-hook.png","caption":"FIG 01 — CF7 form submission and hook lifecycle","description":"A CF7 submission is validated and spam-checked, then wpcf7_before_send_mail fires with the option to abort. If not aborted, CF7 sends the notification email; on success wpcf7_mail_sent fires — the place to hook a webhook — and on failure wpcf7_mail_failed fires instead.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "as_enqueue_async_action: PHP Signature & Parameters" description: "as_enqueue_async_action documentation: PHP signature, the $unique parameter, plus as_schedule_single_action and cancel functions with code examples." url: "https://wpwebhooks.org/blog/action-scheduler-api-functions/" date: "2026-05-24" --- # as_enqueue_async_action: PHP Signature & Parameters TL;DR - Action Scheduler exposes four scheduling functions: single\_action, recurring\_action, cron\_action, and enqueue\_async\_action - All functions return an integer action ID; pass `$unique = true` to skip duplicates automatically - `as_enqueue_async_action` runs in the next queue cycle with no timestamp; `as_schedule_single_action` fires at a specific Unix time - The Webhook Actions plugin uses Action Scheduler under the hood — no manual API calls needed for webhook delivery / Overview ## What is the Action Scheduler PHP API? Action Scheduler is a scalable, auditable background-job library for WordPress, maintained by WooCommerce and documented at [actionscheduler.org](https://actionscheduler.org/). It ships inside WooCommerce and is available as a standalone plugin. Its PHP API is a set of global functions that let you enqueue, schedule, and cancel background actions without touching WordPress's built-in WP-Cron machinery. Every scheduled action maps to a WordPress hook. When the action fires, Action Scheduler calls `do_action( $hook, ...$args )`, so the callback is a regular hook handler. The queue runner — a separate process that pulls and executes pending actions — is the part that makes delivery reliable: it runs outside the page-load cycle, retries failed actions, and keeps a full attempt log. The API has four scheduling functions, two query functions, and two cancellation functions. All are documented in the [official usage guide](https://actionscheduler.org/usage/). / Single Action ## How does **as\_schedule\_single\_action** work? [`as_schedule_single_action`](https://actionscheduler.org/usage/#as_schedule_single_action) schedules a one-time action to run at a specific Unix timestamp. It is the Action Scheduler equivalent of `wp_schedule_single_event` — but backed by a persistent database queue instead of the `cron` option. See the dedicated [as\_schedule\_single\_action reference](https://wpwebhooks.org/blog/as-schedule-single-action-reference/) for the full parameter table and return-value edge cases. PHP — as\_schedule\_single\_action signature ``` // Schedule a one-time action at $timestamp as_schedule_single_action( int $timestamp, // Unix timestamp — when to run string $hook, // WordPress action hook to fire array $args = [], // Arguments passed to do_action( $hook, ...$args ) string $group = '', // Group name for batching / filtering bool $unique = false, // Skip if identical action already pending int $priority = 10 // Queue priority — lower fires first ): int; // Returns the action ID (or existing ID when $unique=true) // Real-world usage: send webhook 5 seconds from now $action_id = as_schedule_single_action( time() + 5, 'my_plugin_send_webhook', [ 'order_id' => $order_id, 'endpoint' => $url ], 'webhooks', true // skip duplicate if same order_id already queued ); ``` The return value is the integer action ID stored in the `wp_actionscheduler_actions` table. When `$unique` is `true` and an identical pending action exists, the existing action's ID is returned without creating a duplicate. This is the primary idempotency mechanism in the Action Scheduler API. For a column-by-column breakdown of that table and the claims, groups and logs tables, see the [Action Scheduler database schema](https://wpwebhooks.org/blog/action-scheduler-database-tables/). / Recurring ## How does **as\_schedule\_recurring\_action** differ? `as_schedule_recurring_action` schedules an action to fire repeatedly at a fixed interval in seconds. After each execution, Action Scheduler automatically re-enqueues the next occurrence at `$timestamp + N * $interval`. Unlike WP-Cron's [`wp_schedule_event`](https://developer.wordpress.org/reference/functions/wp_schedule_event/), missed runs do not stack — Action Scheduler always schedules the next slot after the previous one completes. The dedicated [as\_schedule\_recurring\_action reference](https://wpwebhooks.org/blog/as-schedule-recurring-action-reference/) covers interval values, drift, and duplicate-safe registration in full. PHP — as\_schedule\_recurring\_action signature ``` as_schedule_recurring_action( int $timestamp, // Timestamp of first run int $interval_in_seconds, // Seconds between runs (e.g. 3600 = hourly) string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int; // Register on activation — guard with as_has_scheduled_action if ( ! as_has_scheduled_action( 'my_plugin_hourly_sync' ) ) { as_schedule_recurring_action( time(), 3600, 'my_plugin_hourly_sync', [], 'sync' ); } ``` / Async ## What does **as\_enqueue\_async\_action** do? `as_enqueue_async_action` queues an action to run as soon as the next queue-processing cycle picks it up — no future timestamp required. It is the right choice when you want work to happen in the background immediately, without the overhead of computing a timestamp or managing intervals. The function has the same signature as the scheduling functions but without `$timestamp`. The action is stored with a status of `pending` and the queue runner claims it in its next batch. PHP — as\_enqueue\_async\_action ``` // Run ASAP — next queue cycle, outside the current request $action_id = as_enqueue_async_action( 'my_plugin_process_import', [ 'file' => $upload_path ], 'imports', true // unique — skip if already queued for this file ); // The current HTTP response can now return immediately. // add_action() handler fires later, in the queue runner process. add_action( 'my_plugin_process_import', function( $file ) { // heavy work here — runs outside the user's request } ); ``` / Cron Action ## How does **as\_schedule\_cron\_action** schedule on a cron expression? `as_schedule_cron_action` schedules an action on a cron schedule expression (e.g. `0 6 * * *` for 6 AM daily). Unlike `as_schedule_recurring_action`, which fires at a fixed interval from the previous run, a cron action fires at wall-clock times — it will always run at 6 AM regardless of when the previous run finished. Use it when clock-alignment matters (nightly reports, hourly syncs at the top of the hour). PHP — as\_schedule\_cron\_action ``` as_schedule_cron_action( time(), // find next occurrence after this timestamp '0 6 * * *', // standard cron expression: 06:00 UTC daily 'my_plugin_morning_report', [], 'reports' ); ``` / Query & Cancel ## How do you query and cancel scheduled actions? Two query functions let you inspect the queue before scheduling. `as_has_scheduled_action( $hook, $args, $group )` returns `true` if any matching action is pending or running — use it to avoid duplicate scheduling (see [preventing duplicate scheduled actions](https://wpwebhooks.org/blog/action-scheduler-prevent-duplicate-actions/) for the `$unique` vs guard trade-off and the exact matching rules). `as_next_scheduled_action( $hook, $args, $group )` returns the Unix timestamp of the next run, or `false` if nothing is scheduled. To remove actions: `as_unschedule_action` cancels the next occurrence; `as_unschedule_all_actions` cancels every matching action across all future occurrences. Both accept the same `($hook, $args, $group)` signature and are safe to call even if no action is scheduled. PHP — Query and cancel ``` // Guard before scheduling if ( as_has_scheduled_action( 'my_plugin_sync', [], 'sync' ) ) { return; // already queued } // Next run timestamp (false if nothing scheduled) $next = as_next_scheduled_action( 'my_plugin_sync' ); // Cancel next occurrence only as_unschedule_action( 'my_plugin_sync', [], 'sync' ); // Cancel all occurrences (use on plugin deactivation) as_unschedule_all_actions( 'my_plugin_sync' ); ``` / Deduplication ## How does the **$unique parameter** prevent duplicate actions? When `$unique = true`, Action Scheduler checks whether an identical action — same `$hook`, `$args`, and `$group` — already exists in a `pending` or `running` state before inserting. If one exists, the scheduling call returns the existing action's ID without creating a duplicate. This is critical for webhook dispatch: if an order is saved multiple times in quick succession (common during WooCommerce checkout), you only want one delivery queued. Without `$unique = true`, every save fires a new action and the receiving endpoint gets duplicate payloads. The deduplication check compares the serialized `$args` array, so different argument values always create separate actions even with `$unique = true`. Alternatively, the [Webhook Actions plugin](https://wordpress.org/plugins/flowsystems-webhook-actions/) handles deduplication automatically via its built-in queue: each bound hook fires at most one queued delivery per trigger per webhook, without requiring manual `$unique` management in application code. / Comparison ## Raw WP-Cron vs Action Scheduler: **what is the difference?** | Feature | Raw WP-Cron (wp\_schedule\_\*) | Action Scheduler (as\_schedule\_\*) | | --- | --- | --- | | Storage | wp\_options row — no per-action record | Dedicated DB tables — one row per action | | Delivery log | None — no attempt history | Full attempt log with timestamps and error text | | Retry | No retry — one attempt only | Automatic retry with configurable attempts | | Concurrency | No concurrency control — all jobs share one process | Configurable concurrent batches via filter | | Claim locking | No — duplicate processing possible under load | DB-level claim lock prevents duplicate execution | | Deduplication | None — manual workaround required | $unique parameter checks pending/running queue | | Admin UI | None in core | Status, logs, and manual cancel in WP admin | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Webhook Integration ## How do Webhook Actions and Action Scheduler **work together?** The [Webhook Actions plugin](https://wpwebhooks.org/wordpress-webhook-plugin/) auto-detects Action Scheduler and uses it as the queue runner when available. When Action Scheduler is present, every triggered webhook delivery is enqueued via `as_enqueue_async_action` and executed outside the current PHP request. When Action Scheduler is absent, the plugin falls back to WP-Cron automatically. From a developer perspective you do not call the AS API directly. You bind a WordPress hook (e.g. `woocommerce_order_status_changed`) to a webhook endpoint in the plugin's admin UI, and the plugin handles the rest: payload serialisation, queuing, header injection (`X-Event-Id`, `X-Event-Timestamp`, `X-Webhook-Id`), exponential-backoff retry (1 min → 2 min → 4 min → 8 min, capped at 1 hour, 5 attempts default), and delivery logging. The `fswa_max_attempts` and `fswa_backoff_delay` filters let you tune the retry schedule. See the [Action Scheduler overview](https://wpwebhooks.org/blog/action-scheduler-wordpress/) for the queue-runner architecture and the [concurrent batches guide](https://wpwebhooks.org/blog/action-scheduler-concurrent-batches-filter/) for tuning under high load. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"as_enqueue_async_action: PHP Signature & Parameters","description":"as_enqueue_async_action documentation: PHP signature, the $unique parameter, plus as_schedule_single_action and cancel functions with code examples.","datePublished":"2026-05-24","dateModified":"2026-05-24","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/action-scheduler-api-functions/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["action scheduler","as enqueue async action","action scheduler api","wordpress background jobs","action scheduler functions reference"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"as_enqueue_async_action: PHP Signature & Parameters","item":"https://wpwebhooks.org/blog/action-scheduler-api-functions/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What does as_schedule_single_action return?","acceptedAnswer":{"@type":"Answer","text":"It returns an integer action ID — the primary key of the new row in wp_actionscheduler_actions. When $unique = true and a matching pending action already exists, it returns that existing action's ID without inserting a new row."}},{"@type":"Question","name":"What is the difference between as_enqueue_async_action and as_schedule_single_action?","acceptedAnswer":{"@type":"Answer","text":"as_enqueue_async_action runs the action in the next available queue cycle with no specific timestamp — as soon as the queue runner picks it up. as_schedule_single_action runs at a specific future Unix timestamp. Use async when you want immediate background execution; use single when you need to delay or throttle execution to a specific time."}},{"@type":"Question","name":"How do I prevent duplicate scheduled actions?","acceptedAnswer":{"@type":"Answer","text":"Pass $unique = true as the fifth argument to any scheduling function. Action Scheduler checks for an existing pending or running action with the same hook, args, and group before inserting. The check compares serialised args, so different argument values always create separate actions."}},{"@type":"Question","name":"What happens when as_unschedule_action is called and nothing is scheduled?","acceptedAnswer":{"@type":"Answer","text":"Nothing — both as_unschedule_action and as_unschedule_all_actions are safe to call with no matching actions. They perform a no-op rather than raising an error. It is safe to call these on plugin deactivation regardless of whether any actions were ever scheduled."}},{"@type":"Question","name":"Does Action Scheduler require WooCommerce?","acceptedAnswer":{"@type":"Answer","text":"No. Action Scheduler ships bundled with WooCommerce but is also available as a standalone package via Composer (woocommerce/action-scheduler) or as a standalone plugin. Multiple plugins can bundle their own copy; Action Scheduler automatically resolves version conflicts and uses the highest version present."}}]} ``` --- --- title: "WooCommerce Webhooks via Action Scheduler: Retry & Replay" description: "Send WooCommerce order events as reliable webhooks with Action Scheduler — async queue, retries with backoff, conditional dispatch, and one-click replay." url: "https://wpwebhooks.org/blog/woocommerce-webhooks-action-scheduler/" date: "2026-05-24" --- # WooCommerce Webhooks via Action Scheduler: Retry & Replay TL;DR - WooCommerce's built-in webhook auto-disables after repeated failures, locks you into a fixed payload format, and has no conditional dispatch or replay - Webhook Actions queues every delivery via Action Scheduler, returning control to checkout immediately - Use `fswa_webhook_payload` to enrich order data and `fswa_should_dispatch` to filter by status, total, or product - Failed deliveries retry with exponential backoff and can be bulk-replayed from the admin log without re-triggering WooCommerce hooks / Overview ## Why do WooCommerce webhooks **need a persistent queue**? WooCommerce ships with a built-in webhook system backed by Action Scheduler — deliveries are queued asynchronously just like Webhook Actions. The limitations are elsewhere: failed deliveries are retried a fixed number of times before the entire webhook is auto-disabled, the payload is locked to WooCommerce's own JSON schema, there is no per-delivery conditional logic, and bulk replay after an outage requires re-triggering the original WooCommerce events. High-volume stores — Black Friday traffic, bulk imports — frequently lose delivery windows when the downstream system is briefly unavailable and the built-in retry window is exhausted. Action Scheduler solves the problem by decoupling delivery from the triggering request. The WooCommerce hook fires, a job is queued in the [Action Scheduler](https://actionscheduler.org/) database, and the queue runner delivers the payload asynchronously with full exponential-backoff retry and a queryable delivery log. / WooCommerce Hooks ## Which WooCommerce hooks should **trigger webhook deliveries**? Every major WooCommerce order lifecycle event exposes a WordPress action hook. The most useful for webhook automation are: Note that the first of these is checkout-only and never fires on the blocks checkout — see [when woocommerce\_checkout\_order\_created actually fires](https://wpwebhooks.org/blog/woocommerce-checkout-order-created/) before binding to it. PHP — key WooCommerce order hooks ``` // New order created at checkout add_action( 'woocommerce_checkout_order_created', function( $order ) {} ); // Payment confirmed (works for all payment methods) add_action( 'woocommerce_payment_complete', function( $order_id ) {} ); // Any status transition — from pending to processing, etc. add_action( 'woocommerce_order_status_changed', function( $order_id, $old_status, $new_status, $order ) {}, 10, 4 ); // Specific transition hooks (cleaner for single-status targets) add_action( 'woocommerce_order_status_completed', function( $order_id ) {} ); add_action( 'woocommerce_order_status_refunded', function( $order_id ) {} ); ``` WooCommerce's [built-in webhook system](https://woocommerce.com/document/webhooks/) already subscribes to a subset of these hooks. When using Webhook Actions, you bind the same hooks directly in wp-admin without writing PHP — the plugin handles the listener registration and queuing. / Hook Binding ## How do you bind a WooCommerce hook to a **webhook endpoint**? In Webhook Actions, open wp-admin → Webhook Actions → Add New. Set the **Trigger** to the WooCommerce hook name (e.g. `woocommerce_payment_complete`) and the **Endpoint URL** to your receiving system. The plugin registers the WordPress action listener automatically — no PHP required. For `woocommerce_order_status_changed` (which passes four arguments), set the **Args count** field to `4` so the plugin captures all arguments. The payload mapper then exposes `$order_id`, `$old_status`, `$new_status`, and the `$order` object as payload fields. If you need PHP-level control over the binding — per-environment endpoint URLs, runtime conditions — use the [REST API](https://wordpress.org/plugins/flowsystems-webhook-actions/) to create the webhook programmatically or the `fswa_webhook_url` filter to rewrite the endpoint URL per delivery. / Queue Delivery ## How does Action Scheduler queue **WooCommerce webhook deliveries**? When the WooCommerce hook fires, Webhook Actions calls `as_enqueue_async_action` with the webhook ID and serialized trigger arguments as the action args. The current PHP request returns immediately. In the next queue-runner cycle, the action fires `do_action()` with those args, the plugin reconstructs the payload, and `wp_remote_post` delivers it to your endpoint. The queue runner runs outside the WooCommerce checkout request. A 30-second API timeout on your CRM side will not slow down the customer's order confirmation — it will only delay retry processing in the background. / Payload Mapping ## How do you map WooCommerce order data **to a webhook payload**? Webhook Actions automatically serializes the hook arguments into the base payload. For `woocommerce_payment_complete`, the payload contains `$order_id` as the root value. For richer payloads — order total, line items, billing email — use the `fswa_webhook_payload` filter to enrich the outgoing payload just before dispatch. PHP — enrich WooCommerce webhook payload ``` add_filter( 'fswa_webhook_payload', function( $payload, $webhook_id, $trigger ) { if ( $trigger !== 'woocommerce_payment_complete' ) { return $payload; } $order_id = $payload[0] ?? null; $order = wc_get_order( $order_id ); if ( ! $order ) return $payload; return [ 'order_id' => $order_id, 'status' => $order->get_status(), 'total' => $order->get_total(), 'currency' => $order->get_currency(), 'billing_email' => $order->get_billing_email(), 'line_items' => array_map( fn( $item ) => [ 'product_id' => $item->get_product_id(), 'name' => $item->get_name(), 'quantity' => $item->get_quantity(), ], $order->get_items() ), ]; }, 10, 3 ); ``` / Conditional Dispatch ## How do you filter WooCommerce webhooks **by status or product**? Webhook Actions has a built-in condition builder in the admin UI — no PHP required. As of 3.0.0 there is no cap on the free plan: attach as many conditions per webhook as you need and combine them with AND/OR logic. For conditions that go beyond what the UI supports — checking order totals, product IDs, or any WooCommerce object property — use the `fswa_should_dispatch` filter. It fires before a job is queued, so returning `false` keeps the queue clean rather than delivering and discarding on the receiving end. PHP — skip webhooks for virtual / free orders ``` add_filter( 'fswa_should_dispatch', function( $should, $webhook_id, $trigger, $payload ) { if ( $trigger !== 'woocommerce_payment_complete' ) { return $should; } $order_id = $payload[0] ?? null; $order = wc_get_order( $order_id ); // Skip free orders (total = 0) if ( $order && (float) $order->get_total() === 0.0 ) { return false; } return $should; }, 10, 4 ); ``` / Retry & Replay ## What retry behavior applies to **failed WooCommerce deliveries**? When a delivery returns a 5xx status or a network error, Webhook Actions marks the attempt `failed` and schedules a retry via Action Scheduler with exponential backoff: 1 minute, 2 minutes, 4 minutes, 8 minutes — delays capping at 1 hour between attempts. After 5 attempts (configurable via the `fswa_max_attempts` filter), the delivery is marked `permanently_failed`. 4xx responses (malformed payload, auth failure) are marked `permanently_failed` immediately — no retry, because the problem is structural rather than transient. The delivery log in wp-admin shows every attempt with its HTTP status code and response body. Failed deliveries can be replayed individually or in bulk from the log view — useful when a receiving system undergoes maintenance and you need to re-send a window of missed WooCommerce events without re-triggering the WooCommerce hooks. See the [retry and replay architecture](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/) for the full backoff schedule and bulk replay workflow. / Comparison ## WooCommerce built-in webhooks vs Webhook Actions: **key differences** | Feature | WooCommerce Built-in Webhooks | Webhook Actions + Action Scheduler | | --- | --- | --- | | Delivery timing | Async via Action Scheduler — but delivery schedule and retry window are managed by WooCommerce core, not configurable | Async via Action Scheduler — configurable retry schedule, max attempts, and job grouping | | Retry logic | Repeated failures → webhook auto-disabled by WooCommerce | 5 attempts, exponential backoff, never auto-disabled | | Delivery log | Basic — logs exist but limited history | Full per-attempt log with status codes and response body | | Manual replay | No replay — must re-trigger the WooCommerce event | Replay individual or bulk deliveries from admin log | | Payload control | Fixed WooCommerce payload format | fswa\_webhook\_payload filter — any shape, any enrichment | | Conditional dispatch | No — all matching events are delivered | UI condition builder (free: 1 condition, Pro: unlimited) + fswa\_should\_dispatch filter for advanced logic | | Concurrency | No batching — each delivery is independent | Queue runner with configurable concurrent batches | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WooCommerce Webhooks via Action Scheduler: Retry & Replay","description":"Send WooCommerce order events as reliable webhooks with Action Scheduler — async queue, retries with backoff, conditional dispatch, and one-click replay.","datePublished":"2026-05-24","dateModified":"2026-05-24","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/woocommerce-webhooks-action-scheduler/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["woocommerce webhooks","woocommerce action scheduler","woocommerce webhook delivery","woocommerce order webhook","woocommerce payment complete webhook","woocommerce webhook retry","wordpress woocommerce webhook integration"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WooCommerce Webhooks via Action Scheduler: Retry & Replay","item":"https://wpwebhooks.org/blog/woocommerce-webhooks-action-scheduler/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Which WooCommerce hook fires when a payment is confirmed?","acceptedAnswer":{"@type":"Answer","text":"woocommerce_payment_complete fires after a payment is recorded as complete, regardless of payment method. It receives $order_id as its only argument. woocommerce_checkout_order_created fires earlier, when the order row is first created — before payment confirmation."}},{"@type":"Question","name":"Why does WooCommerce's built-in webhook auto-disable after failures?","acceptedAnswer":{"@type":"Answer","text":"WooCommerce tracks consecutive delivery failures and disables the webhook after a configurable number of failures (default: 5) to prevent persistent retries against a dead endpoint. This protects server resources but means any extended outage on the receiving side permanently stops delivery. Action Scheduler-backed delivery does not auto-disable — it retries up to the configured max_attempts and then marks individual deliveries permanently_failed, leaving the webhook active for future events."}},{"@type":"Question","name":"How do I replay WooCommerce webhook deliveries that failed during an outage?","acceptedAnswer":{"@type":"Answer","text":"In wp-admin → Webhook Actions → Logs, filter by status = permanently_failed and the date range of the outage. Select all matching entries and use the bulk Replay action. Each selected delivery is re-enqueued via Action Scheduler and retried with the original payload — you do not need to re-trigger the WooCommerce hooks or touch the order data."}},{"@type":"Question","name":"Can I send different payloads to different endpoints for the same WooCommerce hook?","acceptedAnswer":{"@type":"Answer","text":"Yes. Create multiple webhooks in Webhook Actions, each pointing to a different endpoint, all bound to the same hook. Use the fswa_webhook_payload filter to shape each payload differently based on $webhook_id. The Pro plan adds Code Glue snippets, which let you attach per-webhook payload transformations directly in the admin UI without filter code."}},{"@type":"Question","name":"Does Action Scheduler add overhead to WooCommerce checkout?","acceptedAnswer":{"@type":"Answer","text":"Minimal. The checkout request calls as_enqueue_async_action, which performs a single DB INSERT into the Action Scheduler tables and returns immediately. All delivery work, including wp_remote_post and retry logic, happens in the queue runner process outside the checkout request. The DB write is the only synchronous cost."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/woocommerce-webhooks-action-scheduler.png","caption":"At checkout WooCommerce fires woocommerce_payment_complete and enqueues an async action, returning the order confirmation to the customer immediately. The queue runner later pulls the batch and posts to the CRM, retrying with backoff on a 5xx and logging each attempt.","description":"At checkout WooCommerce fires woocommerce_payment_complete and enqueues an async action, returning the order confirmation to the customer immediately. The queue runner later pulls the batch and posts to the CRM, retrying with backoff on a 5xx and logging each attempt.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Background Jobs: Async Queue Without Blocking" description: "Process WordPress background jobs asynchronously — non-blocking dispatch, an Action Scheduler queue, retries with backoff, and reliable webhook delivery." url: "https://wpwebhooks.org/blog/wordpress-async-background-processing/" date: "2026-05-24" --- # WordPress Background Jobs: Async Queue Without Blocking TL;DR - `blocking => false` in `wp_remote_post` fires the HTTP call without waiting for a response — but provides no retry or error visibility - WP-Cron defers work to the next page load, not a real timer — use a system cron entry to guarantee minute-level execution - Action Scheduler stores jobs in DB tables, retries failures with exponential backoff, and keeps a full attempt log - The Webhook Actions plugin uses Action Scheduler automatically — no manual queue code required for reliable webhook delivery / Overview ## What is **async background processing** in WordPress? "Async" in WordPress means decoupling work from the current HTTP request so the user's response returns immediately while the heavy work runs later — in a separate process, triggered by a queue runner or cron. Every email, order webhook, or file import that cannot fail silently needs this separation. WordPress has three built-in mechanisms for background work: non-blocking HTTP requests (`blocking => false`), scheduled events via [WP-Cron](https://developer.wordpress.org/plugins/cron/), and [background job queues](https://wpwebhooks.org/blog/wordpress-job-queue/) powered by [Action Scheduler](https://actionscheduler.org/). Each has different guarantees and failure modes. / Why Blocking Fails ## Why does synchronous HTTP **block the WordPress response**? PHP executes synchronously within a single HTTP request. When WordPress calls `wp_remote_post()`, PHP opens a TCP connection, sends the request, and waits for the remote server to respond — all before returning the page to the visitor. A slow or unavailable endpoint can add seconds to page load, trigger PHP's execution timeout, and cause the triggering event to appear to fail even if WordPress itself completed successfully. This is the root cause of most silent webhook failures. The admin action (status change, form submission) completes, but the downstream HTTP call times out or throws an exception that goes unlogged. No queue, no retry, no visibility. / Non-Blocking Requests ## How do you make a **non-blocking fire-and-forget** request? Pass `'blocking' => false` to any [`wp_remote_post`](https://developer.wordpress.org/reference/functions/wp_remote_post/) or `wp_remote_request` call. WordPress's HTTP API initiates the connection and returns control to PHP immediately, without reading the response body or status code. PHP — non-blocking wp\_remote\_post ``` wp_remote_post( $url, [ 'method' => 'POST', 'body' => wp_json_encode( $payload ), 'headers' => [ 'Content-Type' => 'application/json' ], 'blocking' => false, // return immediately, don't read response 'timeout' => 0.01, // very short — we're not waiting anyway 'sslverify' => true, 'data_format' => 'body', ] ); ``` **Limitation:** with `blocking => false` you get no response, no status code, no error information, and no retry if the endpoint is down. This pattern suits simple fire-and-forget notifications (logging, analytics pings) where missing one event is acceptable. For webhook delivery where every event must arrive, a persistent queue is necessary. / WP-Cron Deferred ## How does WP-Cron defer background work **outside the request**? [WP-Cron](https://developer.wordpress.org/plugins/cron/) is WordPress's built-in scheduler. You schedule a hook with `wp_schedule_single_event( time() + 5, 'my_hook', $args )`, then register a handler with `add_action()`. On the next page load after the scheduled time, WordPress fires the hook in a separate process — outside the current request but still driven by HTTP traffic. The critical limitation: WP-Cron fires on page load, not on a real timer. A low-traffic site at 3 AM may wait hours before the next page load triggers the queue. Use a real system cron (`*/1 * * * * curl -s https://site.com/wp-cron.php?doing_wp_cron`) to get minute-level reliability. The [WP-Cron developer reference](https://wpwebhooks.org/blog/wp-cron-developer-reference/) covers the full function set. / Action Scheduler ## How does Action Scheduler **decouple jobs from the request cycle**? Action Scheduler goes further than WP-Cron: it stores every pending action in its own database tables, runs a dedicated queue runner via a system-level process (or as a WP-Cron job as fallback), and keeps a full attempt log per action. When an action fails, it is automatically retried with exponential backoff rather than silently dropped. The queue runner claims a batch of pending actions, marks each as `processing`, executes the corresponding `do_action()` call, and marks the result `complete` or `failed`. Because claim locking is DB-level, multiple concurrent runners cannot process the same action twice — which is the core guarantee that makes async dispatch reliable at scale. / Queue Job Pattern ## How do you implement a **background queue job** in WordPress? The pattern has two parts: enqueue the work, and register the handler. With Action Scheduler, enqueuing is one line. The handler is a standard WordPress action callback — it can do any work that doesn't need to return a value to the calling request. PHP — queue job pattern with Action Scheduler ``` // 1. Enqueue the job when the event fires add_action( 'woocommerce_payment_complete', function( $order_id ) { as_enqueue_async_action( 'my_plugin_send_crm_webhook', [ 'order_id' => $order_id ], 'webhooks', true ); } ); // 2. Handle the job in the queue runner process add_action( 'my_plugin_send_crm_webhook', function( $order_id ) { $order = wc_get_order( $order_id ); $payload = [ 'email' => $order->get_billing_email(), /* ... */ ]; $response = wp_remote_post( $crm_url, [ 'body' => wp_json_encode( $payload ), 'headers' => [ 'Content-Type' => 'application/json' ], ] ); if ( is_wp_error( $response ) ) { throw new Exception( $response->get_error_message() ); // Action Scheduler catches the exception and retries } } ); ``` / Comparison ## Blocking PHP vs async background dispatch: **what changes?** | Concern | Blocking wp\_remote\_post | Webhook Actions (async queue) | | --- | --- | --- | | Response time | Adds endpoint latency to every page load | Returns immediately — job runs in queue runner | | Retry on failure | None — one attempt, silent drop | Exponential backoff: 1 m → 2 m → 4 m → 8 m, 5 attempts | | Error visibility | WP\_Error swallowed unless you log it yourself | Attempt log with HTTP status and response body | | Delivery guarantee | Best-effort — timeout or 5xx = lost | Persistent queue — survives PHP crash or server restart | | Request isolation | Failure aborts the current user request | Job failure never affects the triggering request | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Plugin Option ## How does Webhook Actions handle **async dispatch automatically**? The [Webhook Actions plugin](https://wpwebhooks.org/wordpress-webhook-plugin/) wraps the entire queue-job pattern described above. You bind a hook to an endpoint in wp-admin; the plugin enqueues every triggered delivery via Action Scheduler (or WP-Cron as fallback), injects identity headers (`X-Event-Id`, `X-Event-Timestamp`, `X-Webhook-Id`), retries 5xx failures with exponential backoff, and logs every attempt in a queryable delivery log — all without writing PHP. Custom payload shaping is available via the `fswa_webhook_payload` filter or via Code Glue snippets. For integrations that need conditional dispatch, the `fswa_should_dispatch` filter lets you skip delivery based on payload fields, order status, or any WordPress state — keeping the queue clean rather than delivering and discarding on the receiving end. See the [async webhooks article](https://wpwebhooks.org/blog/async-webhooks-wordpress/) for the end-to-end architecture, and [retry and replay](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/) for the backoff schedule and manual replay workflow. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Background Jobs: Async Queue Without Blocking","description":"Process WordPress background jobs asynchronously — non-blocking dispatch, an Action Scheduler queue, retries with backoff, and reliable webhook delivery.","datePublished":"2026-05-24","dateModified":"2026-05-24","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-async-background-processing/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["wordpress async background processing","wordpress async request","wordpress queue job","wp remote post blocking","action scheduler async","wordpress background jobs","non blocking http wordpress"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Background Jobs: Async Queue Without Blocking","item":"https://wpwebhooks.org/blog/wordpress-async-background-processing/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What does blocking => false actually do in wp_remote_post?","acceptedAnswer":{"@type":"Answer","text":"It instructs WordPress's HTTP API to open the connection and send the request without waiting for a response. PHP returns control to your code immediately. The response body is never read, so you cannot check the HTTP status code or detect errors. Use it only when delivery confirmation is not required."}},{"@type":"Question","name":"Is a non-blocking wp_remote_post guaranteed to deliver?","acceptedAnswer":{"@type":"Answer","text":"No. If the remote server is unreachable or the OS has not finished flushing the TCP send buffer before PHP terminates, the request may not be sent at all. blocking => false is fire-and-forget at the PHP layer, not a guaranteed-delivery mechanism. For reliable delivery, use Action Scheduler with a persistent queue and automatic retry."}},{"@type":"Question","name":"Does WP-Cron run without traffic?","acceptedAnswer":{"@type":"Answer","text":"Not by default. WP-Cron fires on page load, so it depends on incoming HTTP requests to tick. On low-traffic sites the queue may sit idle for hours. The standard fix is to disable WP-Cron in wp-config.php (define('DISABLE_WP_CRON', true)) and call wp-cron.php from a real system cron every minute."}},{"@type":"Question","name":"What is the difference between WP-Cron and Action Scheduler for background jobs?","acceptedAnswer":{"@type":"Answer","text":"WP-Cron stores scheduled events in a single wp_options row with no attempt history, no retry, and no delivery log. Action Scheduler uses dedicated database tables, stores every action individually, retries failures automatically with exponential backoff, and keeps a full per-action log. For webhook delivery, Action Scheduler is the right choice."}},{"@type":"Question","name":"Can I use wp_remote_post for webhook delivery in production?","acceptedAnswer":{"@type":"Answer","text":"You can, but you need to add all the reliability infrastructure manually: enqueue the call outside the current request, add retry logic on 5xx responses, store attempt history, and build a replay mechanism. The Webhook Actions plugin provides all of this out of the box, built on Action Scheduler."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-async-background-processing-1.png","caption":"A browser POST to checkout makes WordPress call wp_remote_post inline, so PHP blocks while the remote API takes anywhere from five to thirty seconds. The user response is held until the API answers or times out.","description":"A browser POST to checkout makes WordPress call wp_remote_post inline, so PHP blocks while the remote API takes anywhere from five to thirty seconds. The user response is held until the API answers or times out.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-async-background-processing-2.png","caption":"A browser POST to checkout enqueues an async action and WordPress returns a fast 200 without calling the remote API. The queue runner later pulls pending actions, fires the hook, calls wp_remote_post, and retries on a 5xx.","description":"A browser POST to checkout enqueues an async action and WordPress returns a fast 200 without calling the remote API. The queue runner later pulls pending actions, fires the hook, calls wp_remote_post, and retries on a 5xx.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "action_scheduler_queue_runner_concurrent_batches Docs" description: "action_scheduler_queue_runner_concurrent_batches filter docs: official parameters, batch-size tuning, and code examples for Action Scheduler." url: "https://wpwebhooks.org/blog/action-scheduler-concurrent-batches-filter/" date: "2026-05-17" --- # action_scheduler_queue_runner_concurrent_batches Docs TL;DR - `action_scheduler_queue_runner_concurrent_batches` is a **filter**, not a constant — return an integer (default since AS 3.5 is 5). - It controls how many parallel loopback requests Action Scheduler fires per cron tick. Pair it with `action_scheduler_queue_runner_batch_size` (default 25) to multiply throughput. - Raising it on shared hosting will exhaust PHP workers and 502 your site — tune only when you control PHP-FPM pool size and have a real queue backlog. / Definition ## What does **action\_scheduler\_queue\_runner\_concurrent\_batches** actually do? It caps the number of parallel batches the Action Scheduler queue runner fires per cron tick. Each batch is a separate `wp_remote_post` loopback request hitting `admin-post.php?action=as_async_request_queue_runner` on your own site, which runs one PHP process that drains up to `batch_size` actions before exiting. With the default of 5 concurrent batches and 25 actions per batch, one cron tick can dispatch up to 125 actions. With the historical default of 1 batch, it could only dispatch 25. On a site queuing 10,000 actions per hour (high-volume WooCommerce, bulk email, sync jobs), the 1-batch ceiling is exactly why the queue grows faster than it drains. The filter fires inside `ActionScheduler_QueueRunner::has_maximum_concurrent_batches()` — see the [QueueRunner source on GitHub](https://github.com/woocommerce/action-scheduler/blob/trunk/classes/ActionScheduler_QueueRunner.php). It runs every time the cron tick evaluates whether to dispatch another loopback, so the value is read live, not cached. Changes take effect on the next tick. / Filter vs Constant ## Is it a **filter** or a constant? It is a filter, applied via `apply_filters( 'action_scheduler_queue_runner_concurrent_batches', $batches )`. There is no PHP constant by that name and defining one in `wp-config.php` has no effect. Misreading the GSC search query as a constant is the most common mistake on Stack Overflow. The related `action_scheduler_queue_runner_batch_size` is also a filter, not a constant. Both must be hooked in PHP — typically from an `mu-plugin` so the values survive plugin deactivation and theme switches. / Tuning ## How do I **raise the concurrent-batch limit safely**? Start by confirming there is a real backlog. Open **WooCommerce → Status → Scheduled Actions** (the [Action Scheduler admin docs](https://actionscheduler.org/admin/) describe the columns) or run `wp action-scheduler queue-status`, and check the _Pending_ count. If pending is consistently under 100 and the oldest pending action is under five minutes old, the queue is already draining — tuning will burn CPU for no throughput gain. If pending is in the thousands and growing, raise the values together. `concurrent_batches × batch_size` equals the maximum actions processed per cron tick. Doubling concurrent batches without raising batch size produces only marginal gains because most batches finish quickly and exit. Each parallel batch consumes one PHP-FPM worker for the duration of the batch. With PHP-FPM `pm.max_children = 10` and `concurrent_batches = 5`, half your worker pool can be tied up draining actions — leaving five workers for actual page loads. Below 10 workers, leave the default. Above 30 workers, raising to 5 (the modern default) is safe. mu-plugins/action-scheduler-tuning.php — production-safe defaults ``` // wp-content/mu-plugins/action-scheduler-tuning.php // Raise Action Scheduler throughput on a host that can handle it. add_filter( 'action_scheduler_queue_runner_concurrent_batches', function() { return 5; // default 5 since AS 3.5; lower it to 1 on shared hosting } ); add_filter( 'action_scheduler_queue_runner_batch_size', function() { return 50; // actions processed per batch; default 25 } ); add_filter( 'action_scheduler_queue_runner_time_limit', function() { return 30; // seconds; stay well under PHP max_execution_time } ); ``` try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Companion Filter ## What is the relationship with **action\_scheduler\_queue\_runner\_batch\_size**? Batch size is actions _per_ batch; concurrent batches is the number of batches per tick. Throughput per minute equals roughly `batch_size × concurrent_batches × ticks_per_minute`. Raising one without the other hits the wrong bottleneck. Raise `batch_size` when individual actions are fast (a few HTTP calls or DB writes) and the per-batch startup overhead dominates. Raise `concurrent_batches` when individual actions are slow (long-running imports, large emails) and you want to parallelize across PHP workers. The hard ceiling on `batch_size` is your PHP `max_execution_time`. If your batches consistently hit the `action_scheduler_queue_runner_time_limit` (30s by default) and exit early, lower `batch_size` instead of raising the time limit — early-exit batches re-enter the queue cleanly, but PHP `max_execution_time` kills leave actions in a _processing_ state that needs manual recovery. / Inspection ## How do I **see the current values** on a live site? Run `wp eval "echo apply_filters( 'action_scheduler_queue_runner_concurrent_batches', 5 );"` from the site root. The output is the effective value after all hooked filters apply. Run the same against `action_scheduler_queue_runner_batch_size` with a default of 25. For the live throughput picture, run `wp action-scheduler run --batches=1` and read the action count and elapsed time. Multiply by the tick frequency (60 ticks/hour on a one-minute cron) and compare against your enqueue rate. If enqueue exceeds throughput, the backlog will grow no matter how patiently you wait. / Hosting Pitfalls ## Which hosting environments **break when you raise these**? Shared hosting with low `pm.max_children` values (Bluehost, GoDaddy, SiteGround starter plans). Raising concurrent batches above 1 fills the PHP worker pool with self-issued loopback requests and returns 502 to real visitors. Symptom: site goes down at the top of every minute when the cron tick fires. Raising it also widens the window for [MySQL deadlocks on the claim query](https://wpwebhooks.org/blog/woocommerce-action-scheduler-mysql-deadlocks/) when multiple runners collide. Hosts that block loopback requests (some managed WordPress hosts, Cloudflare with strict firewall rules). Action Scheduler's loopback dispatch silently fails when `admin-post.php?action=as_async_request_queue_runner` is blocked, falling back to in-process execution that ignores the concurrent batches filter entirely. PHP-FPM with `pm = ondemand` and aggressive `pm.process_idle_timeout`. Concurrent batches create burst load, FPM spawns workers, then kills them — paying the worker startup cost on every tick. Use `pm = dynamic` with a sensible `pm.min_spare_servers` instead. On managed WordPress hosting (Kinsta, WP Engine, Pressable), check the host's docs before raising concurrent batches. Many enforce their own queue runner via a system cron and document recommended values — overriding them risks support deflection ("we don't support custom Action Scheduler tuning"). / WP-Cron Interaction ## How does this interact with **WP-Cron and loopback requests**? Action Scheduler's queue runner is dispatched _by_ WP-Cron via the [`action_scheduler_run_queue` scheduled event](https://wpwebhooks.org/blog/action-scheduler-run-queue-hook/) — confirmed in the [Action Scheduler architecture docs](https://actionscheduler.org/). If WP-Cron does not fire — because no page request triggered it, or because `DISABLE_WP_CRON` is set without a system cron — the queue runner never starts and the concurrent\_batches filter is never consulted. On a low-traffic site, the realistic first fix is not raising concurrent\_batches. It is replacing page-load-triggered WP-Cron with a system cron entry hitting `wp-cron.php` every minute. See [the WP-Cron replacement guide](https://wpwebhooks.org/blog/cron-job-for-wordpress/) for the exact crontab line. Once a real cron is firing the queue runner reliably, each parallel batch spawned by the concurrent\_batches filter issues a _second_ internal HTTP request — a loopback to `admin-post.php`. Five concurrent batches = five concurrent loopbacks per tick. This is the layer that consumes PHP workers and trips host rate limits. / When to Stop ## When should you **stop tuning and switch to a real queue**? When you are tuning these filters more than once a quarter, the workload has outgrown Action Scheduler. The signal is consistent: pending stays above 5,000 even after tuning, the runner spends most of its time on retries, and you have started adding custom cron entries to run the queue runner more often than every minute. At that point the next step is offloading dispatch to a real queue (Redis, SQS, RabbitMQ) with a dedicated worker process outside the PHP request cycle — the same architecture covered in [Why WordPress Webhooks Silently Fail in Production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/). The Webhook Actions plugin sits in between: it ships its own persistent queue tables with smart retry routing and a delivery log, and uses Action Scheduler as the runner when available (falling back to WP-Cron). You configure concurrency once, then operate from the admin console. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"action_scheduler_queue_runner_concurrent_batches Docs","description":"action_scheduler_queue_runner_concurrent_batches filter docs: official parameters, batch-size tuning, and code examples for Action Scheduler.","datePublished":"2026-05-17","dateModified":"2026-05-17","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/action-scheduler-concurrent-batches-filter/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["action scheduler queue runner concurrent batches","action scheduler concurrent batches","action scheduler queue runner batch size","action scheduler tuning","woocommerce background processing","wp cron throughput"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"action_scheduler_queue_runner_concurrent_batches Docs","item":"https://wpwebhooks.org/blog/action-scheduler-concurrent-batches-filter/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is action_scheduler_queue_runner_concurrent_batches a constant or a filter?","acceptedAnswer":{"@type":"Answer","text":"It is a filter, applied via apply_filters('action_scheduler_queue_runner_concurrent_batches', $batches). There is no constant by that name; defining one in wp-config.php has no effect. You hook the filter in PHP — typically from an mu-plugin — and return an integer."}},{"@type":"Question","name":"What is the default value?","acceptedAnswer":{"@type":"Answer","text":"Since Action Scheduler 3.5 the default is 5. Earlier versions defaulted to 1. Check your installed version with wp plugin get woocommerce --field=version because Action Scheduler ships inside WooCommerce."}},{"@type":"Question","name":"How is action_scheduler_queue_runner_batch_size different?","acceptedAnswer":{"@type":"Answer","text":"Batch size is the number of actions processed per batch (default 25). Concurrent batches is the number of parallel batches per cron tick (default 5). Throughput per tick equals batch_size × concurrent_batches. Tune both together — raising one without the other hits the wrong bottleneck."}},{"@type":"Question","name":"Why does raising concurrent batches return 502 errors?","acceptedAnswer":{"@type":"Answer","text":"Each parallel batch consumes one PHP-FPM worker via an internal loopback request. If concurrent_batches × batch_duration exceeds your available PHP workers, real visitor requests have no worker to serve them and the front-end load balancer returns 502. Lower the value or raise pm.max_children."}},{"@type":"Question","name":"How can I check the effective value on a live site?","acceptedAnswer":{"@type":"Answer","text":"Run wp eval echo apply_filters action_scheduler_queue_runner_concurrent_batches 5 from the site root. The output is the effective value after all hooked filters apply."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/action-scheduler-concurrent-batches-filter.png","caption":"The Action Scheduler queue is drained by a per-minute cron tick. With the default of one concurrent batch the runner fires a single loopback request of 25 actions; tuned to five concurrent batches it fires five loopback requests, processing about 125 actions per tick.","description":"The Action Scheduler queue is drained by a per-minute cron tick. With the default of one concurrent batch the runner fires a single loopback request of 25 actions; tuned to five concurrent batches it fires five loopback requests, processing about 125 actions per tick.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Send Gravity Forms Submissions to n8n via Webhook" description: "Forward Gravity Forms entries to n8n with a reliable, retry-capable webhook — payload mapping, HMAC signing, error handling, and end-to-end test recipe." url: "https://wpwebhooks.org/blog/gravity-forms-to-n8n-webhook/" date: "2026-05-17" --- # Send Gravity Forms Submissions to n8n via Webhook TL;DR - Trigger: `gform_after_submission` (or `_{id}` for one form). Pair with n8n's **Webhook trigger node**. - Webhook Actions auto-attaches `X-Event-Id`, `X-Event-Timestamp`, `X-Webhook-Id` for n8n-side deduplication. - Always route through the queue — n8n downtime triggers automatic retry (1m, 2m, 4m, 8m…) instead of silent data loss. / Overview ## What is the **simplest** way to send Gravity Forms data to n8n? Add an HTTP webhook to your form that POSTs the entry as JSON to an n8n Webhook trigger node. The Gravity Forms [official Webhooks add-on](https://docs.gravityforms.com/category/add-ons-gravity-forms/webhooks-add-on/) can do this, and so can Webhook Actions. The choice between them comes down to one question: what happens when n8n is unreachable? The Gravity Forms Webhooks add-on fires the request inline during form submission. If n8n returns a 5xx, times out, or is down for maintenance, the submission still completes on the WordPress side but the entry never reaches n8n — and there is no record of the failure beyond a single line in the form's feed log. Production deployments need a queue with automatic retry; that is the gap Webhook Actions fills. In both cases the n8n side is identical: a Webhook trigger node with Header Auth turned on, then the workflow does the actual work (write to a CRM, push to Slack, append to a sheet). / n8n Side ## Which **n8n trigger node** do you use? The [**Webhook** node](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/), configured with HTTP method POST and Response Mode set to "Immediately" with response code 200. Switch the workflow to active, copy the Production URL (not the Test URL — the test URL only works while the editor is open), and use that as the destination in your WordPress configuration. Authentication tab: pick _Header Auth_, header name `Authorization`, value `Bearer `. On the WordPress side, add the same `Authorization: Bearer ...` as a Custom Header on the webhook in Webhook Actions. Two clicks each side, no code, no shared signing secrets to rotate. Without header auth, anyone who guesses your workflow URL can post arbitrary payloads. Response data: return `{"received": true}` only after every required field is present in the payload. Returning 200 to a malformed payload tells the WordPress side the delivery succeeded — when really the workflow exited at step two without doing anything. Validate the payload shape in n8n and return a 400 explicitly when it fails. / Payload Mapping ## How do you **build the Gravity Forms webhook payload**? Two paths. The no-code path: in **Webhook Actions → Add Webhook**, paste the n8n Production URL, pick trigger [`gform_after_submission`](https://docs.gravityforms.com/gform_after_submission/) (or `gform_after_submission_{form_id}` for a single form), submit the form once to capture an example payload, then use the **Payload Mapping** editor to rename Gravity Forms field IDs (`1.3`, `1.6`, `2`) to semantic keys (`first_name`, `last_name`, `email`). Save. The next submission dispatches with the mapped shape. The code path is for advanced enrichment — looking up a CRM record id from post meta, signing the body, injecting computed fields. Use the `fswa_webhook_payload` filter, which runs just before dispatch and receives the mapped payload, webhook ID, trigger name, and the original pre-mapping payload (see the [plugin README on WordPress.org](https://wordpress.org/plugins/flowsystems-webhook-actions/) for the full filter list). The plugin's admin UI exposes a Field Selector with live preview so most rename / restructure work never needs PHP. Optional enrichment via fswa\_webhook\_payload filter ``` // Inject a computed field into the outgoing payload for one webhook. add_filter( 'fswa_webhook_payload', function( $payload, $webhook_id, $trigger, $original ) { if ( $trigger !== 'gform_after_submission' ) { return $payload; } // Pull a CRM contact id stored on the entry, fall back to lookup by email. $payload['crm_contact_id'] = my_crm_lookup_contact( $payload['email'] ); return $payload; }, 10, 4 ); ``` Scope the filter on `$webhook_id` when multiple webhooks listen to the same trigger but need different enrichment. See the [gform\_after\_submission deep dive](https://wpwebhooks.org/blog/gravity-forms-gform-after-submission-webhook/) for hook argument details. / Security ## How do you **secure the n8n webhook** (bearer token + event IDs)? Two layers, both no code. Layer one: bearer token via the plugin's built-in Custom Headers. Open the webhook in **Webhook Actions → Edit**, add a Custom Header `Authorization` with value `Bearer `, save. In the n8n Webhook node, set Authentication to _Header Auth_ with name `Authorization` and value `Bearer `. Anyone who guesses your workflow URL still cannot post. Layer two: dedup with the headers Webhook Actions attaches automatically. `X-Event-Id` is a UUID stable across retries — store the last N values in n8n (a Set node + workflow static data, or a small Redis lookup) and exit early on a repeat. This matters because the queue retries on 5xx, and the same event can arrive twice if n8n returned 200 just after a transient error. That covers 95% of real deployments. **You only need HMAC body signing if** you do not trust the network path (running n8n on the public internet without TLS pinning, or piping through a third-party proxy that could mutate the body). In that case hook the `fswa_headers` filter, compute `hash_hmac('sha256', $body, $secret)`, inject as `X-Signature`, and verify in an n8n Function node. Bearer + TLS is enough for almost everyone else. / Reliability ## How do you **handle retries when n8n is offline**? Queue the payload in WordPress; do not POST inline. Webhook Actions does this by default — every dispatch goes through a queue table with status, attempt count, and per-attempt request/response history. When n8n is unreachable, retries fire with exponential backoff (1m → 2m → 4m → 8m, capped at 1h between attempts; 5 attempts before `permanently_failed` — see the [plugin README "Smart Retry" section](https://wordpress.org/plugins/flowsystems-webhook-actions/)). An inline `wp_remote_post` in `gform_after_submission` gets one chance, no log, and the entry is gone. On the n8n side, return a 5xx (not a 4xx) when the workflow fails for a transient reason — a downstream API is rate-limiting, a CRM is briefly down. Webhook Actions treats 4xx as _permanent_ and stops retrying; 5xx and 429 trigger the next backoff. Use 5xx for "try again," 4xx for "the payload is wrong, do not retry." See [retry vs replay](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/) for the data model behind both. / Field Mapping ## How do you **map Gravity Forms fields** to n8n nodes? Inside the n8n Webhook node, the payload arrives under `$json.body` (or directly under `$json` depending on response settings). Reference fields with expressions like `{{ $json.body.email }}` in downstream nodes. The semantic-key approach from the previous section means n8n expressions read like English instead of `{{ $json.body["2"] }}`. For repeating fields (multiple checkboxes, dynamic field rows), the value is a comma-separated string in the Gravity Forms `$entry` array. Split it in the PHP mapper before sending — `explode( ',', $entry['5'] )` — so n8n receives a real array and can `Split In Batches` over it cleanly. File uploads: Gravity Forms stores the URL in the entry array as a serialized JSON string for multi-file fields, or a plain URL for single-file. Forward the URL (n8n can fetch it with an HTTP Request node) rather than the file bytes — keeps the webhook payload small and avoids n8n timeouts on large attachments. / Testing ## How do you **test the flow end-to-end**? 1\. In n8n, click _Listen for test event_ on the Webhook node and submit a test entry from Gravity Forms' preview. Confirm the payload arrives and any HMAC verification passes. 2\. Switch the n8n workflow to Active, change the WordPress webhook to the Production URL, and submit a real entry. Watch the Webhook Actions delivery log for status 200. 3\. Simulate failure: change the WordPress webhook URL to a typo and submit again. Confirm the log shows the retry schedule and the entry lands in `permanently_failed` after the final attempt — that is your dead-letter path working. Skip step 3 at your peril. The first time most n8n integrations fail in production is on a Saturday night when no one is watching the logs. / Tooling Choice ## When should you **skip the Gravity Forms Webhooks add-on** and use Webhook Actions instead? | Concern | Gravity Forms Webhooks add-on | Webhook Actions | | --- | --- | --- | | Dispatch | Inline (blocks submission) | Persistent queue (Action Scheduler when available) | | Retry on n8n 5xx | Single attempt | 5 attempts, exponential backoff up to 1h | | Failure log | Single line per feed | Per-attempt history with request & response | | Event identity | None | X-Event-Id, X-Event-Timestamp, X-Webhook-Id automatic | | Replay | Manual re-submit | Replay any logged event from admin or REST | | Conditional dispatch | Per-feed conditions | Free: 1 condition; Pro: unlimited groups w/ AND/OR | | Bearer auth | Manual workaround | Built-in Custom Headers — zero code | | HMAC body signing | Not built in | Add via fswa\_headers filter (5 lines) when needed | | REST API control | None | Token-auth REST (logs, retry, replay, queue) | | Cost | Bundled with GF Elite | Free; Pro for unlimited conditions, per-webhook retry | The add-on is fine for low-volume forms where occasional data loss is tolerable. For lead capture, e-commerce checkouts, and anything that costs money when an entry is missed, the queue-backed approach pays for itself the first time n8n has a five-minute outage. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Send Gravity Forms Submissions to n8n via Webhook","description":"Forward Gravity Forms entries to n8n with a reliable, retry-capable webhook — payload mapping, HMAC signing, error handling, and end-to-end test recipe.","datePublished":"2026-05-17","dateModified":"2026-05-17","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/gravity-forms-to-n8n-webhook/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["gravity forms n8n","gravity forms webhook n8n","gform after submission n8n","gravity forms to n8n","n8n webhook trigger gravity forms"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Send Gravity Forms Submissions to n8n via Webhook","item":"https://wpwebhooks.org/blog/gravity-forms-to-n8n-webhook/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Can I connect Gravity Forms to n8n without writing code?","acceptedAnswer":{"@type":"Answer","text":"Yes — both the Gravity Forms Webhooks add-on (bundled with GF Elite) and Webhook Actions support a no-code configuration. The add-on fires inline during submission with one attempt and no retry. Webhook Actions queues the dispatch, retries on n8n outages with exponential backoff up to one hour, and logs every attempt with the full request and response."}},{"@type":"Question","name":"Which n8n trigger node receives Gravity Forms submissions?","acceptedAnswer":{"@type":"Answer","text":"The Webhook node. Set HTTP method to POST, copy the Production URL (not the Test URL), and paste it into your Gravity Forms webhook destination. Enable Header Auth with an X-API-Key for a first layer of access control, then verify HMAC in a Function node."}},{"@type":"Question","name":"How do I authenticate the request so n8n knows it came from my WordPress site?","acceptedAnswer":{"@type":"Answer","text":"Easiest: bearer token via the plugin's built-in Custom Headers. Add a Custom Header named Authorization with value Bearer on the webhook in Webhook Actions, then enable Header Auth on the n8n Webhook node with the same name and value. No code on either side. Use HMAC body signing only when you do not trust the transport — hook the fswa_headers filter, compute hash_hmac sha256, and verify in an n8n Function node."}},{"@type":"Question","name":"What happens to entries when n8n is offline?","acceptedAnswer":{"@type":"Answer","text":"With the Gravity Forms Webhooks add-on they are lost — the dispatch fires inline, sees the failure, logs one line in the feed, and moves on. With Webhook Actions they enter a retry queue with exponential backoff (1m, 2m, 4m, 8m, capped at 1h between attempts; 5 attempts by default). Failed entries land in the permanently_failed list, which is queryable and replayable from the admin UI or REST API."}},{"@type":"Question","name":"How do I send a specific Gravity Forms form to n8n while leaving others alone?","acceptedAnswer":{"@type":"Answer","text":"Hook gform_after_submission_FORMID instead of gform_after_submission. The suffix is the form ID. For example add_action gform_after_submission_3 fires only on form 3. Inside the callback, build the payload and enqueue the webhook for that one form."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/gravity-forms-to-n8n-webhook.png","caption":"Gravity Forms fires gform_after_submission and Webhook Actions posts a JSON payload with a bearer token and event headers to an n8n Webhook trigger node, which verifies auth, branches on form id, and maps fields onward before responding 200. If n8n is down or returns 5xx, Webhook Actions retries with exponential backoff up to a one-hour cap.","description":"Gravity Forms fires gform_after_submission and Webhook Actions posts a JSON payload with a bearer token and event headers to an n8n Webhook trigger node, which verifies auth, branches on form id, and maps fields onward before responding 200. If n8n is down or returns 5xx, Webhook Actions retries with exponential backoff up to a one-hour cap.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WP-Cron Documentation: Every Function, Constant & CLI" description: "The WP-Cron documentation WordPress never wrote: wp_schedule_event, wp_doing_cron, spawn_cron, DISABLE_WP_CRON, and every WP-CLI cron command." url: "https://wpwebhooks.org/blog/wp-cron-developer-reference/" date: "2026-05-17" --- # WP-Cron Documentation: Every Function, Constant & CLI TL;DR - WP-Cron is not a real scheduler — it is a function called `wp_cron()` that fires on page load and dispatches a non-blocking loopback to `wp-cron.php`. - `wp_doing_cron()` returns true only inside that loopback request (when `DOING_CRON` is defined). Use it to guard long-running callbacks. - `DISABLE_WP_CRON` in `wp-config.php` disables the page-load trigger but does not disable `wp-cron.php` itself — that is the foundation of every real-cron fix. / Definition ## What is **WP-Cron** and how does it actually fire? WP-Cron is a userland scheduler written in PHP. It is not a Unix cron daemon. It runs entirely inside WordPress and is triggered by HTTP requests to your site — most commonly by visitor page loads, but also by the WordPress admin, the REST API, and explicit calls to `spawn_cron()`. The flow on every request: WordPress boots, runs the `wp_cron()` function near the end of `wp_loaded`, checks the `cron` option in the database for events whose `time` field is in the past, and — if any are due — calls `spawn_cron()` to fire a non-blocking `wp_remote_post()` loopback request against `wp-cron.php`. The visitor's response is returned without waiting for the cron run to finish. The canonical handler is `wp-cron.php` at the WordPress root. It defines the `DOING_CRON` constant, loads WordPress, iterates the due events, and runs `do_action( $hook, $args )` for each. See the [official WP-Cron handbook](https://developer.wordpress.org/plugins/cron/) for the canonical description of this lifecycle. / wp\_doing\_cron() ## What does **wp\_doing\_cron()** return and when do you use it? It returns `true` when the current PHP request is the cron loopback — that is, when the `DOING_CRON` constant has been defined. It returns `false` during normal visitor requests, admin requests, REST requests, and WP-CLI commands (except for `wp cron event run`, which sets `DOING_CRON` manually). Use it to guard long-running work that should never block a user-facing request. A common pattern: an `add_action('my_event', ...)` callback that does file processing wraps its body in `if ( wp_doing_cron() )` as a defensive check, so if the hook is ever fired in another context the work is skipped instead of stalling the request. Internally it is a thin wrapper around `wp_doing_cron()` applies the filter of the same name. The signature is `wp_doing_cron(): bool`. See the [wp\_doing\_cron() reference](https://developer.wordpress.org/reference/functions/wp_doing_cron/). wp\_doing\_cron() and spawn\_cron() in practice ``` // Inside any request that wants to trigger overdue cron events. if ( ! wp_doing_cron() && _get_cron_array() ) { spawn_cron(); // fires a non-blocking loopback to /wp-cron.php?doing_wp_cron } // Inside a callback registered with add_action('my_cron_event', ...). function my_cron_callback() { if ( wp_doing_cron() ) { // Safe to do long-running work — no user is waiting on this PHP request. } } ``` / spawn\_cron() ## How does **spawn\_cron()** work under the hood? It fires a `wp_remote_post()` request to `site_url('/wp-cron.php?doing_wp_cron=')` with `blocking => false`. The current request continues immediately; the loopback request runs in a separate PHP process. This is the mechanism that lets the user-facing response return quickly even when cron has work to do. See the [spawn\_cron() reference](https://developer.wordpress.org/reference/functions/spawn_cron/). Before firing, `spawn_cron()` writes a `doing_cron` transient with the same timestamp. This is a one-minute soft lock — a second concurrent visitor will see the transient is set, will not call `spawn_cron()` again, and will trust that the existing loopback is in progress. The transient prevents a swarm of overlapping cron runs on a busy site. It is safe to call `spawn_cron()` from your own code if you want to force-fire overdue events without waiting for the next page load. Wrap it in `if ( ! wp_doing_cron() )` to avoid recursive loopbacks. / DISABLE\_WP\_CRON ## What does **DISABLE\_WP\_CRON** actually disable? Defined as `define( 'DISABLE_WP_CRON', true );` in `wp-config.php`, it suppresses the page-load trigger only. The check happens inside [`wp_cron()`](https://developer.wordpress.org/reference/functions/wp_cron/) — if the constant is truthy, the function exits immediately without spawning anything. Visitor requests stop firing cron. It does _not_ disable `wp-cron.php` itself. The script remains publicly accessible and still iterates due events when hit directly. This is the entire point: pair `DISABLE_WP_CRON` with a system crontab entry that hits `wp-cron.php` every minute, and you get reliable time-based execution decoupled from traffic. It also does not disable scheduling — `wp_schedule_event()` still writes to the `cron` option, plugins still register their schedules, and the events still execute when `wp-cron.php` runs. The constant only affects _when_ cron runs, not _whether_ it runs. Common mistake: setting `DISABLE_WP_CRON` without configuring a real cron. The page-load trigger is gone, no system cron exists, so `wp-cron.php` never runs and every scheduled event silently stops firing. Always pair them — see [the system cron setup guide](https://wpwebhooks.org/blog/cron-job-for-wordpress/). / WP-CLI ## Which **WP-CLI commands** run a real cron sweep? [`wp cron event run --due-now`](https://developer.wordpress.org/cli/commands/cron/event/run/) is the workhorse. It runs every event whose `time` is in the past, in the current PHP process (no loopback), with `DOING_CRON` defined so `wp_doing_cron()` behaves correctly inside callbacks. This is what you put in your system crontab. Use `wp cron event run ` to fire a single named event on demand — useful during plugin development and when investigating a single misbehaving callback. Use `wp cron event list` for inventory: every registered event, its next-run time, recurrence interval, and the hook name. `wp cron test` issues a loopback to `wp-cron.php` and reports whether the server can reach itself (a common failure on managed hosts with strict outbound firewalls). WP-CLI cron commands — the daily-driver subset ``` # List every scheduled event. wp cron event list # Run every due event right now, in this PHP process (no loopback). wp cron event run --due-now # Run one specific hook on demand. wp cron event run my_cron_event # Show registered schedules (hourly, twicedaily, daily, plus custom). wp cron schedule list # Check whether wp-cron.php is reachable from the server itself. wp cron test ``` / Real Cron ## How do you **replace WP-Cron with a real system cron**? Two lines of configuration. First, in `wp-config.php` above the `/* That's all, stop editing! */` comment: `define( 'DISABLE_WP_CRON', true );`. Second, in the system crontab for the web user (commonly `www-data` on Debian/Ubuntu): /etc/cron.d/wordpress — guaranteed one-minute execution ``` # Runs every minute regardless of site traffic. WP-CLI variant preferred when available. * * * * * www-data /usr/local/bin/wp --path=/var/www/site cron event run --due-now >/dev/null 2>&1 # Curl fallback if WP-CLI is not installed: * * * * * www-data curl -s https://your-site.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1 ``` The WP-CLI form is preferred because it runs in-process, captures errors directly, and avoids the HTTP loopback (which can be blocked, throttled, or rate-limited by upstream proxies). The curl form is the universal fallback and works on shared hosting where you cannot install WP-CLI. / Scheduling API ## Where do **wp\_schedule\_event** and **wp\_schedule\_single\_event** fit? [`wp_schedule_event( $timestamp, $recurrence, $hook, $args, $wp_error )`](https://developer.wordpress.org/reference/functions/wp_schedule_event/) registers a recurring event. `$recurrence` must match a registered schedule name (`hourly`, `twicedaily`, `daily`, `weekly` in core; `cron_schedules` filter to add custom). It writes to the `cron` option and is idempotent only when `$args` match exactly — different args produce different scheduled events. [`wp_schedule_single_event( $timestamp, $hook, $args )`](https://developer.wordpress.org/reference/functions/wp_schedule_single_event/) registers a one-shot event. It deduplicates within a ten-minute window: scheduling the same hook + args twice within ten minutes produces only one event. Use it as the WordPress-native pattern for "do this work later, off the request path." Pair both with `add_action( $hook, $callback )` in the same execution path that will run cron — typically the same plugin file, so the callback is registered every time WordPress loads. / vs Action Scheduler ## How is **WP-Cron different from Action Scheduler**? | Capability | WP-Cron | Action Scheduler | | --- | --- | --- | | Storage | cron option (autoloaded) | Dedicated DB tables | | Scale ceiling | ~100 events before option bloat | 100k+ actions per day | | Concurrency | Single loopback per tick | Configurable concurrent batches | | Failure recovery | Silent — event just doesn't run | Per-action status + retry | | Observability | No log of past runs | Status > Scheduled Actions UI | | Trigger | Page load or system cron | Same — uses WP-Cron underneath | Action Scheduler is built on top of WP-Cron — its queue runner is itself a scheduled cron event. Replacing WP-Cron with a system cron makes Action Scheduler reliable; the two work together rather than competing. See [the Action Scheduler concurrent batches reference](https://wpwebhooks.org/blog/action-scheduler-concurrent-batches-filter/) for the throughput tuning that depends on a reliable cron firing the queue runner. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WP-Cron Documentation: Every Function, Constant & CLI","description":"The WP-Cron documentation WordPress never wrote: wp_schedule_event, wp_doing_cron, spawn_cron, DISABLE_WP_CRON, and every WP-CLI cron command.","datePublished":"2026-05-17","dateModified":"2026-05-17","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wp-cron-developer-reference/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["wordpress wp cron official documentation","wp doing cron","spawn cron","disable wp cron","wp cron event run due now","what is wp cron","wp cron developer reference"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WP-Cron Documentation: Every Function, Constant & CLI","item":"https://wpwebhooks.org/blog/wp-cron-developer-reference/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is WP-Cron in plain terms?","acceptedAnswer":{"@type":"Answer","text":"WP-Cron is a PHP function inside WordPress that pretends to be a scheduler. It fires on page load — when a visitor hits any page, WordPress checks for overdue scheduled events and runs them in a separate background HTTP request. It is not a real Unix cron daemon."}},{"@type":"Question","name":"What does wp_doing_cron() return outside of the cron loopback?","acceptedAnswer":{"@type":"Answer","text":"It returns false. wp_doing_cron() returns true only when the DOING_CRON constant is defined — which happens inside wp-cron.php and inside wp cron event run. In normal visitor requests, admin pages, and REST requests it returns false."}},{"@type":"Question","name":"Does DISABLE_WP_CRON disable wp_schedule_event?","acceptedAnswer":{"@type":"Answer","text":"No. DISABLE_WP_CRON only disables the page-load trigger. wp_schedule_event still writes events to the cron option, plugins still register schedules, and the events still execute when wp-cron.php is hit directly or by WP-CLI."}},{"@type":"Question","name":"What does wp cron event run --due-now do?","acceptedAnswer":{"@type":"Answer","text":"It runs every scheduled event whose time is in the past, in the current PHP process, with DOING_CRON defined. No HTTP loopback is fired. This is the WP-CLI command you put in your system crontab for reliable execution."}},{"@type":"Question","name":"Why doesn't my scheduled event ever fire?","acceptedAnswer":{"@type":"Answer","text":"Three common causes: DISABLE_WP_CRON is set without a system cron, so wp-cron.php never runs; the site has near-zero traffic and the page-load trigger never fires; or the callback was registered with add_action in a code path that does not load during the cron request. Run wp cron event list to confirm the event is scheduled, then wp cron event run hook_name to confirm the callback executes."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wp-cron-developer-reference.png","caption":"On a visitor request WordPress boots and, unless DISABLE_WP_CRON is truthy, wp_cron reads the cron array. With no events due it ends the request; with events due spawn_cron sends a non-blocking request to wp-cron.php, which runs in a separate PHP process that defines DOING_CRON and fires each due event before exiting.","description":"On a visitor request WordPress boots and, unless DISABLE_WP_CRON is truthy, wp_cron reads the cron array. With no events due it ends the request; with events due spawn_cron sends a non-blocking request to wp-cron.php, which runs in a separate PHP process that defines DOING_CRON and fires each due event before exiting.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Action Scheduler WordPress: Full Docs & Reference" description: "Action Scheduler WordPress documentation: queue runner, retry logic, concurrency tuning, and webhook delivery — official function reference." url: "https://wpwebhooks.org/blog/action-scheduler-wordpress/" date: "2026-05-07" --- # Action Scheduler WordPress: Full Docs & Reference TL;DR - Action Scheduler stores jobs in a database table, not the transient-based WP-Cron option — jobs survive PHP crashes and are never lost on restart - `action_scheduler_queue_runner_concurrent_batches` controls parallel batch execution — default 5, tune based on server capacity - Webhook Actions has its own database queue for jobs; when Action Scheduler is available, WA uses it to trigger its queue runner — replacing WP-Cron with AS's more reliable loopback-based dispatch / Overview ## What is Action Scheduler and **how does it differ from WP-Cron**? Action Scheduler is a scalable, database-backed background job library for WordPress. It was originally developed by Automattic for WooCommerce Subscriptions, extracted into a standalone library, and is now bundled with WooCommerce core. It is used by WooCommerce, WooCommerce Payments, WooCommerce Subscriptions, and over 100 other plugins as the foundation for reliable background processing. The core architectural difference from WP-Cron is storage. WP-Cron stores scheduled events in the `_cron` WordPress option as a serialized PHP array. This means all scheduled events are loaded into memory on every page load, updating a single option row creates write contention under concurrent traffic, and the data structure is not designed for per-job status tracking or queryability. Action Scheduler stores every job in dedicated database tables: `wp_actionscheduler_actions`, `wp_actionscheduler_logs`, `wp_actionscheduler_groups`, and `wp_actionscheduler_claims`. Each job has its own row with a status column (`pending`, `in-progress`, `complete`, `failed`, `canceled`), a scheduled date, an attempt count, and a full execution log. This structure makes per-job tracking, concurrent processing, and failure recovery possible in ways WP-Cron cannot support. | Feature | WP-Cron | Action Scheduler | | --- | --- | --- | | Storage | options table (\_cron) | Dedicated DB tables | | Triggers | Page load (traffic-dependent) | WP-Cron + loopback HTTP requests | | Per-job status | None | pending / in-progress / complete / failed | | Concurrent processing | Single batch | Configurable parallel batches | | Failure tracking | None | Per-action log with error message | | Admin visibility | WP-CLI only | wp-admin/admin.php?page=action-scheduler | | Survives PHP crash | Jobs may be lost | DB row survives, claimed jobs time out and retry | For lightweight, infrequent tasks — a weekly digest email, a daily log rotation — WP-Cron with a [real system cron entry](https://wpwebhooks.org/blog/cron-job-for-wordpress/) is sufficient. Action Scheduler becomes the right choice when you need persistent job storage, per-job failure tracking, concurrent batch processing, or an admin UI for job inspection and management. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / API ## How does **as\_schedule\_single\_action** work? `as_schedule_single_action()` is the primary API function for scheduling a one-time background job. It inserts a row into `wp_actionscheduler_actions` and returns the action ID. The job fires once at the specified timestamp, then its status is updated to `complete` or `failed`. For the full parameter list, return values, and all related scheduling functions, see the [Action Scheduler API Functions reference](https://wpwebhooks.org/blog/action-scheduler-api-functions/). Function signature ``` /** * @param int $timestamp Unix timestamp when the action should run * @param string $hook The WordPress action hook to fire * @param array $args Arguments to pass to the hook callback * @param string $group Optional group name for organizing and querying actions * @return int The action ID */ as_schedule_single_action( $timestamp, $hook, $args = [], $group = '' ); ``` Scheduling a webhook delivery job ``` // Schedule a webhook delivery job to run immediately (time = now). $action_id = as_schedule_single_action( time(), // fire as soon as the next queue runner cycle 'my_plugin_deliver_webhook', [ 'endpoint' => 'https://hooks.example.com/order', 'payload' => wp_json_encode( $payload ), 'attempt' => 1, ], 'my-plugin-webhooks' // group for admin UI filtering ); // Register the callback that fires when the action runs. add_action( 'my_plugin_deliver_webhook', function( $endpoint, $payload, $attempt ) { $response = wp_remote_post( $endpoint, [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => $payload, 'timeout' => 15, ] ); $code = wp_remote_retrieve_response_code( $response ); if ( is_wp_error( $response ) || $code >= 500 || $code === 429 ) { // Retry: schedule a new action at exponential backoff interval. if ( $attempt < 5 ) { $delay = 60 * ( 2 ** ( $attempt - 1 ) ); as_schedule_single_action( time() + $delay, 'my_plugin_deliver_webhook', [ 'endpoint' => $endpoint, 'payload' => $payload, 'attempt' => $attempt + 1 ], 'my-plugin-webhooks' ); } } }, 10, 3 ); ``` The key design: each retry is a new `as_schedule_single_action` call, not a modification of the original action row. The original action row ends with status `complete` (the callback ran without throwing) even if the delivery failed — the retry logic is application-level, above the Action Scheduler abstraction. This is the pattern Webhook Actions uses internally. Other scheduling functions follow the same signature pattern: `as_schedule_recurring_action()` for repeating jobs and `as_enqueue_async_action()` for jobs that should run as soon as possible on the next queue cycle. / Concurrency ## What is **action\_scheduler\_queue\_runner\_concurrent\_batches**? `action_scheduler_queue_runner_concurrent_batches` is both a PHP constant and a filter that controls how many Action Scheduler batch processes can run simultaneously. The default value is 5. For the full tuning guide — when to raise it, when raising it 502s your site, and the relationship with `action_scheduler_queue_runner_batch_size` — see the [Action Scheduler Concurrent Batches Filter & Tuning Guide](https://wpwebhooks.org/blog/action-scheduler-concurrent-batches-filter/). Each "batch" is a separate PHP process initiated by Action Scheduler through a loopback HTTP request to `wp-admin/admin-ajax.php`. When the queue runner fires, it checks how many batches are currently in-progress and spawns additional batches up to the configured limit. Each batch claims a set of pending actions from the database, processes them in sequence, then exits. This design means Action Scheduler can process multiple jobs simultaneously on servers where parallel PHP execution is available — which is true of virtually all WordPress hosting environments. A queue of 50 pending webhooks with 5 concurrent batches of 10 actions each can complete in roughly the time it takes to process a single batch of 10. The claiming mechanism in `wp_actionscheduler_claims` prevents two concurrent batches from processing the same action. When a batch starts, it inserts a claim row for each action it intends to process. Other batches will not select actions that already have an active claim. If a batch crashes mid-execution, the claim row remains — a stuck-detection sweep resets these to `pending` after a configurable timeout. Under heavy concurrency the claim UPDATE itself can collide, producing [MySQL deadlocks (error 1213)](https://wpwebhooks.org/blog/woocommerce-action-scheduler-mysql-deadlocks/) — usually fixed by serializing to one concurrent batch. / Tuning ## How do you **control queue concurrency** with the constant? You can override the concurrent batch limit using either a PHP constant in `wp-config.php` or a WordPress filter. The constant takes precedence over the filter when both are present. wp-config.php — set concurrency via constant ``` // Limit Action Scheduler to 3 concurrent batches. // Useful on shared hosting or memory-constrained environments. define( 'ACTION_SCHEDULER_QUEUE_RUNNER_CONCURRENT_BATCHES', 3 ); ``` functions.php — set concurrency via filter ``` // Filter version — can be conditional (e.g. based on environment). add_filter( 'action_scheduler_queue_runner_concurrent_batches', function( $batches ) { return 3; } ); ``` When to lower this value from the default of 5: - **Shared hosting** — PHP worker pool is limited, typically 4–8 processes total. Five concurrent AS batches can exhaust the entire worker pool and block front-end requests. - **Memory-constrained VPS** — each batch is a full WordPress bootstrap. At 128MB per process, five concurrent batches consume over 640MB of PHP memory before any plugin code runs. - **Database contention** — high-frequency loopback requests creating claims on a busy `wp_actionscheduler_actions` table can cause MySQL lock contention. Reducing concurrency reduces claim write frequency. When to raise this value: - **Dedicated VPS or cloud server** — ample PHP workers and memory available; increasing to 10–20 batches dramatically increases webhook throughput under load. - **WooCommerce high-volume sites** — large order volumes during flash sales or promotions generate many simultaneous webhook jobs; higher concurrency reduces queue drain time. / Queue Runner ## What does the **queue runner** do and when does it fire? The Action Scheduler queue runner is the process that inspects the `wp_actionscheduler_actions` table for overdue pending actions and dispatches them. It fires in two ways: via WP-Cron (traffic-dependent, unreliable on low-traffic sites) and via loopback HTTP requests that Action Scheduler itself initiates. The WP-Cron trigger runs on a one-minute schedule. On each execution, the runner checks whether any batch slots are available, then initiates loopback HTTP requests for up to `action_scheduler_queue_runner_concurrent_batches` additional batch workers. Each batch worker is a separate PHP process that runs independently of the original cron trigger. This means even with WP-Cron as the sole trigger, Action Scheduler can sustain high throughput: the cron fires once per minute, spawns five parallel batch workers, and each worker processes a batch of actions. The loopback workers run to completion independently — they do not depend on the original cron process staying alive. For production reliability, the same advice applies as for WP-Cron: add a real system cron entry that calls the cron endpoint every minute. This decouples Action Scheduler's trigger from page load traffic. Without a real system cron, a low-traffic site at 3am may not trigger the queue runner for hours, leaving queued webhook jobs unprocessed. /etc/cron.d/wordpress — real system cron for Action Scheduler ``` # Fire WP-Cron every minute — triggers Action Scheduler queue runner * * * * * www-data curl -s https://yoursite.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1 ``` WP-CLI provides direct queue runner access for development and debugging. `wp action-scheduler run` runs the queue runner once synchronously, processing one batch of pending actions. This is useful for testing without waiting for the cron cycle. / Webhook Integration ## How does Action Scheduler **power webhook retry queues**? Action Scheduler's properties map directly to the requirements for reliable webhook delivery. Database-backed persistence means a queued delivery survives PHP crashes. Per-job status means you can inspect every delivery attempt. Concurrent batches mean a high-volume WooCommerce store can drain its webhook queue quickly. The admin UI means you can manually retry a failed delivery without touching code. The typical pattern for webhook delivery on top of Action Scheduler: 1. **Capture:** On the WordPress action hook (WooCommerce order complete, CF7 submission, etc.), serialize the payload and call `as_schedule_single_action(time(), 'my_deliver_webhook', $args)`. The user-facing request ends here — no HTTP call made yet. 2. **Dispatch:** The queue runner picks up the action. The callback calls `wp_remote_post()` and inspects the response code. 3. **Retry on transient failure:** On 5xx or timeout, the callback schedules a new `as_schedule_single_action` with a calculated backoff delay and incremented attempt count. 4. **Permanent failure:** After the maximum attempt count, no new action is scheduled. The failure is logged in the Action Scheduler log with the final status code and error message. Webhook Actions applies a variation of this pattern: it maintains its own database queue for jobs rather than storing payloads in Action Scheduler action rows. What WA uses Action Scheduler for is triggering its queue runner — when AS is available, it registers an AS action to fire the queue runner on each cycle instead of relying on WP-Cron. The delivery log in the Webhook Actions admin panel is drawn from WA's own queue tables, not from the Action Scheduler log. / Monitoring ## What are the **failure modes** and how do you monitor them? Action Scheduler has four failure modes that are worth monitoring in production: - **Growing pending queue** — The count of rows with `status = 'pending'` grows continuously without draining. Cause: queue runner is not firing (WP-Cron not receiving traffic, system cron misconfigured) or the runner fires but all batch slots are occupied by slow workers. Check: `wp action-scheduler list --status=pending --format=count`. Growing past a few hundred warrants investigation. - **Stuck in-progress actions** — Actions with `status = 'in-progress'` for longer than the batch timeout (default: 30 seconds per action). Cause: PHP crash, OOM kill, or server restart while a batch was running. The claim is never released. Fix: Action Scheduler's stuck-action detection sweeps these to `failed` after the timeout. - **Accumulating failed actions** — Spike in failed actions indicates a systematic endpoint problem: URL changed, authentication broke, or a code update broke the payload schema. Query: `wp action-scheduler list --status=failed --after=2026-05-07 --format=table`. Inspect the error messages for patterns. - **Table size growth** — Completed and failed actions accumulate in `wp_actionscheduler_actions` and `wp_actionscheduler_logs` over time. Action Scheduler runs a cleanup job that prunes old completed actions (default: keep 30 days). If the table grows beyond tens of millions of rows, the cleanup interval or retention period may need tuning. The Action Scheduler admin page at `wp-admin/admin.php?page=action-scheduler` provides visual status counts, filterable by status, hook, group, and date range. For automated monitoring, query the tables directly or use WP-CLI in a health-check script. / Webhook Actions ## How does **Webhook Actions integrate** with Action Scheduler? Webhook Actions maintains its own database-backed queue for outbound jobs — separate from the Action Scheduler tables. Every webhook trigger writes a job row to WA's queue rather than calling `as_schedule_single_action()`. The payload, attempt count, and retry state live in WA's own schema and are not stored in `wp_actionscheduler_actions`. Where Action Scheduler comes in is queue triggering. WA checks at runtime whether Action Scheduler is available. If it is, WA registers an AS recurring action to fire its internal queue runner on each cycle. This replaces the WP-Cron trigger with AS's more reliable mechanism: loopback HTTP requests, concurrent batch dispatch, and AS's own stuck-action detection. If AS is not present, WA falls back to a WP-Cron scheduled event. The practical result: on any site with WooCommerce (which ships AS), Webhook Actions automatically gets AS-quality trigger reliability without requiring you to configure anything. The delivery log in the Webhook Actions admin is sourced from WA's own queue tables, not from the Action Scheduler log — so `wp action-scheduler list` will show the recurring trigger action, not individual webhook delivery jobs. Tuning `ACTION_SCHEDULER_QUEUE_RUNNER_CONCURRENT_BATCHES` affects how many concurrent AS batch workers fire WA's queue runner, which can increase the frequency of queue drain cycles on high-volume sites. The webhook delivery concurrency itself is controlled separately within WA's queue configuration. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Action Scheduler WordPress: Full Docs & Reference","description":"Action Scheduler WordPress documentation: queue runner, retry logic, concurrency tuning, and webhook delivery — official function reference.","datePublished":"2026-05-07","dateModified":"2026-05-07","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/action-scheduler-wordpress/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["action scheduler wordpress","as schedule single action","action scheduler queue runner concurrent batches","wordpress background jobs","action scheduler concurrency","wordpress async request"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Action Scheduler WordPress: Full Docs & Reference","item":"https://wpwebhooks.org/blog/action-scheduler-wordpress/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is action_scheduler_queue_runner_concurrent_batches?","acceptedAnswer":{"@type":"Answer","text":"action_scheduler_queue_runner_concurrent_batches is a constant (and a filter) that controls how many Action Scheduler batch processes can run simultaneously. The default value is 5 on most configurations. Each batch is a separate PHP process initiated by a loopback HTTP request. Setting this to a higher value increases throughput for large queues but also increases server memory usage. Set it too high on a shared host and you will exhaust PHP worker pool capacity."}},{"@type":"Question","name":"How does as_schedule_single_action work?","acceptedAnswer":{"@type":"Answer","text":"as_schedule_single_action($timestamp, $hook, $args, $group) schedules a one-time action to fire at $timestamp. It inserts a row into the wp_actionscheduler_actions table with status 'pending'. When the Action Scheduler queue runner next executes and finds a pending action whose scheduled_date_gmt is in the past, it claims the action (sets status to 'in-progress'), fires the hook, and marks it 'complete' or 'failed' depending on whether the callback threw an exception. The function returns the action ID."}},{"@type":"Question","name":"Does Action Scheduler come with WooCommerce or is it a separate install?","acceptedAnswer":{"@type":"Answer","text":"Action Scheduler ships bundled with WooCommerce — if WooCommerce is active on your site, Action Scheduler is already installed. It is also available as a standalone Composer package (woocommerce/action-scheduler) for use in plugins that do not depend on WooCommerce. When multiple plugins bundle different versions of Action Scheduler, WordPress automatically uses the newest version present — the versioning system is designed for this."}},{"@type":"Question","name":"How do I view Action Scheduler jobs in the WordPress admin?","acceptedAnswer":{"@type":"Answer","text":"Navigate to wp-admin/admin.php?page=action-scheduler. This admin page lists all actions with filters for status (pending, in-progress, complete, failed, canceled), hook name, group, and date range. You can manually run, cancel, or delete individual actions from this view. The page is available when WooCommerce is active — or when any plugin that bundles Action Scheduler registers it. If the menu item does not appear, the plugin that ships Action Scheduler may not have registered the admin page."}},{"@type":"Question","name":"What happens when an Action Scheduler action fails?","acceptedAnswer":{"@type":"Answer","text":"When a callback throws an uncaught exception or returns an error, Action Scheduler marks the action as 'failed' and records the error message. Failed actions are not automatically retried — you must explicitly reschedule them or use a retry-aware pattern that catches exceptions and calls as_schedule_single_action for the next attempt. Webhook Actions manages its own retry logic in its own queue: WA uses Action Scheduler only to trigger its queue runner, not to store individual delivery jobs, so retries are tracked in WA's own tables rather than as new AS actions."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/action-scheduler-wordpress-1.png","caption":"When the queue runner fires it checks how many batches are in progress; below the concurrent-batches limit it spawns another batch via a loopback request, otherwise it skips. Each batch claims pending actions, executes their hook callbacks, updates status, and releases the claim.","description":"When the queue runner fires it checks how many batches are in progress; below the concurrent-batches limit it spawns another batch via a loopback request, otherwise it skips. Each batch claims pending actions, executes their hook callbacks, updates status, and releases the claim.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/action-scheduler-wordpress-2.png","caption":"A WordPress action hook serializes its payload and calls as_schedule_single_action, so the user-facing request completes before any HTTP call is made. Seconds later the queue runner executes the callback, completing on a 2xx, rescheduling on 5xx or 429, and failing permanently on a 4xx.","description":"A WordPress action hook serializes its payload and calls as_schedule_single_action, so the user-facing request completes before any HTTP call is made. Seconds later the queue runner executes the callback, completing on a 2xx, rescheduling on 5xx or 429, and failing permanently on a 4xx.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Contact Form 7 Webhook: Send Submissions to Any URL" description: "Contact Form 7 webhook setup: the wpcf7_mail_sent hook, field mapping, and queued delivery with retries, so every submission reaches its endpoint." url: "https://wpwebhooks.org/blog/contact-form-7-webhook/" date: "2026-05-07" --- # Contact Form 7 Webhook: Send Submissions to Any URL TL;DR - CF7 has no native webhook feature — you either hook `wpcf7_before_send_mail` manually or use a plugin - Raw PHP delivery fires inline during the form request and silently drops entries when the endpoint is slow or down - Webhook Actions enqueues the CF7 payload into its own database queue, dispatches asynchronously via a background worker (triggered by Action Scheduler when available), maps fields by name, and retries on failure / Native CF7 ## What does Contact Form 7 **do natively** with webhook delivery? Contact Form 7 handles form submission natively in three ways: it sends email notifications, stores submissions optionally in the database (via Flamingo), and displays a confirmation message to the user. That is the extent of its built-in integration capabilities. There is no webhook sender, no HTTP POST action, and no API integration layer in CF7 core. This is a deliberate design choice. CF7 positions itself as a lightweight form plugin. External integrations are explicitly delegated to third-party plugins and custom code — unlike [Elementor Pro, which ships a Webhook action](https://wpwebhooks.org/blog/elementor-forms-webhook/) in the form widget itself. The plugin exposes action hooks — `wpcf7_before_send_mail`, `wpcf7_mail_sent`, `wpcf7_submit` — that developers use to attach custom processing. CF7 is installed on more than 5 million active WordPress sites as of 2025. Its adoption means virtually every automation platform that targets WordPress has CF7 coverage — but that coverage is always provided by a plugin or custom code, not by CF7 itself. For a CF7-to-webhook integration without any code, [the Webhook Actions CF7 example](https://wpwebhooks.org/examples/cf7-to-webhook/) walks through the complete plugin setup with field mapping and n8n integration. / The Hook ## What is the **wpcf7\_before\_send\_mail** hook? `wpcf7_before_send_mail` is the primary action hook developers use to intercept CF7 submissions. It fires after the form passes validation and CF7 prepares the notification email — but before the email is sent. At this point, the full submission data is available through the `WPCF7_Submission` singleton. The hook passes a single argument: the `WPCF7_ContactForm` object representing the form that was submitted. To access the submitted field values, you call `WPCF7_Submission::get_instance()` inside the callback. Hook signature and accessing submission data ``` add_action( 'wpcf7_before_send_mail', function( $contact_form ) { $submission = WPCF7_Submission::get_instance(); if ( ! $submission ) { return; } // Associative array: field name => submitted value $data = $submission->get_posted_data(); // Access individual fields by their CF7 name attribute $name = isset( $data['your-name'] ) ? $data['your-name'] : ''; $email = isset( $data['your-email'] ) ? $data['your-email'] : ''; $message = isset( $data['your-message'] ) ? $data['your-message'] : ''; // Form metadata $form_id = $contact_form->id(); $form_title = $contact_form->title(); } ); ``` Field names in `get_posted_data()` correspond to the `name` attribute you set in the CF7 form editor. A field written as `[text* your-name]` in the CF7 template produces a key `'your-name'` in the data array. This is stable as long as you do not rename the field in the form editor — it does not change if you rearrange fields or update the label. `wpcf7_before_send_mail` also passes three additional arguments if you accept them: the current mail instance, the mail 2 (reply-to) instance, and the additional mail (CC) instance. These are rarely needed for webhook dispatch but useful if you want to modify email content alongside triggering an integration. / Basic Implementation ## How do you send CF7 data to a **webhook URL with raw PHP**? The raw PHP implementation hooks `wpcf7_before_send_mail`, extracts the field values from the submission object, builds a payload array, and calls `wp_remote_post()`. This is the pattern you find in most CF7 webhook tutorials. functions.php — raw CF7 webhook delivery ``` add_action( 'wpcf7_before_send_mail', function( $contact_form ) { $submission = WPCF7_Submission::get_instance(); if ( ! $submission ) { return; } $data = $submission->get_posted_data(); $payload = [ 'form_title' => $contact_form->title(), 'name' => $data['your-name'] ?? '', 'email' => $data['your-email'] ?? '', 'message' => $data['your-message'] ?? '', 'submitted' => current_time( 'mysql', true ), ]; wp_remote_post( 'https://your-n8n.example.com/webhook/cf7', [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $payload ), 'timeout' => 10, ] ); // No retry. No queue. No log. Failure is silent and permanent. } ); ``` This pattern produces a working webhook for simple cases and controlled environments. The problems only emerge under production load — which we cover in the next section. / Production Reality ## What **breaks** when you do it manually? The raw PHP implementation has three structural production problems. None surface during development. **Inline execution slows the form response.** `wpcf7_before_send_mail` runs synchronously inside the PHP request that handled the form POST. The `wp_remote_post()` call blocks that request until the webhook endpoint responds. On a slow n8n instance, Zapier with cold-start latency, or any endpoint under momentary load, every CF7 submission waits for that HTTP roundtrip before showing the confirmation message. This is directly visible to the person filling in the form. **Timeout failures silently drop submissions.** `wp_remote_post()` defaults to a 5-second timeout. Endpoints that take longer — including those serving legitimate requests — produce a `WP_Error` that goes unlogged and unretried. The contact form entry is not stored anywhere (CF7 core does not save to a database by default), so a timed-out webhook delivery means the submission is permanently gone. **No visibility into what happened.** WordPress does not log outbound HTTP calls. A failed `wp_remote_post()` inside a hook produces no admin notice, no PHP error log entry (unless you explicitly check the return value and log it), and no indication to the site owner. The form submitter sees the confirmation message. The webhook never arrived. No one knows. | Aspect | Raw PHP (wpcf7 hook) | Webhook Actions (queued) | | --- | --- | --- | | Execution model | Inline, blocks PHP request | Async background worker | | Timeout handling | Submission lost permanently | Retry with exponential backoff | | Endpoint downtime | All submissions in window lost | Queue holds until endpoint recovers | | Failure visibility | None | Per-attempt log with status codes | | CF7 database dependency | CF7 doesn't save entries by default | Payload serialized to queue before attempt | | Field name changes | Code breaks silently | UI mapping — update without deploying | CF7 core does not save submission data to the database unless the [Flamingo plugin](https://wordpress.org/plugins/flamingo/) is active. This makes reliable webhook delivery especially critical for CF7 integrations — if the raw PHP delivery fails, the submission data may exist nowhere. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Webhook Actions ## How does **Webhook Actions** handle CF7 webhook delivery? Webhook Actions listens to CF7 submissions via its own `wpcf7_before_send_mail` listener. When a CF7 form is submitted, the plugin serializes the submission data into its own database queue before any HTTP delivery is attempted. The form confirmation renders to the user without waiting for any outbound HTTP call. Webhook Actions' queue runner picks up the job on the next cycle — typically seconds on a properly configured site — and dispatches the webhook from a background process completely isolated from the original form request. When Action Scheduler is available (e.g. WooCommerce is active), WA uses it to trigger its queue runner reliably; otherwise it falls back to WP-Cron. If the endpoint fails, the job is rescheduled with exponential backoff and retried up to five times. Because the payload is serialized into Webhook Actions' own queue at hook time — before any delivery attempt — the submission data is safe even if WordPress crashes, the server reboots, or the endpoint is down for hours. When the endpoint comes back online, the queued jobs resume delivery. Configuration is entirely UI-based. You select the Contact Form 7 trigger, optionally filter by form ID, enter the webhook URL, and configure field mappings. No code required. The full setup walkthrough is in the [CF7 to Webhook example](https://wpwebhooks.org/examples/cf7-to-webhook/). / Field Mapping ## How do you **map CF7 form fields** to webhook payload keys? By default, Webhook Actions sends the complete CF7 `get_posted_data()` array as the webhook payload. This includes every field value keyed by its CF7 field name, plus CF7 internal keys like `_wpcf7`, `_wpcf7_version`, and `_wpnonce`. For most integrations you want a clean, selective payload — only the fields your downstream system needs, with human-readable key names. Field mapping in Webhook Actions lets you define exactly which CF7 fields to include and what to name them in the payload. In the mapping UI, you enter a payload key (e.g., `contact_name`) and select the corresponding CF7 field name (e.g., `your-name`). The plugin builds the mapped payload at dispatch time. Equivalent PHP for a field-mapped CF7 webhook payload ``` // This is what Webhook Actions builds from the mapping UI configuration. // No code to write — shown here for reference only. $payload = [ 'contact_name' => $data['your-name'] ?? '', 'email' => $data['your-email'] ?? '', 'message' => $data['your-message'] ?? '', 'phone' => $data['tel-541'] ?? '', 'form_id' => $contact_form->id(), 'submitted_at' => current_time( 'mysql', true ), ]; ``` CF7 checkbox fields store their values as arrays. A checkbox group named `checkbox-menu` with three checked options produces `$data['checkbox-menu'] = ['Option A', 'Option C']`. When you map this field in Webhook Actions, the array is serialized as a JSON array in the payload — `["Option A","Option C"]`. Most downstream systems handle JSON arrays natively. File upload fields (`[file your-file]` in CF7) store the uploaded file path in the submission data, not a URL. To send a usable URL in the webhook payload, you need to convert the path. Webhook Actions handles this conversion automatically — file upload fields are exposed as their full public URL in the mapping UI. / Testing ## How do you **test your CF7 webhook**? Testing a CF7 webhook integration requires verifying three things: the payload structure arriving at the endpoint, the behavior when the endpoint is unavailable, and the field mapping output for each field type in your form. ### 1\. Inspect the payload structure Use [webhook.site](https://webhook.site) as a temporary endpoint during development. It gives you a unique URL that logs every incoming request — headers, body, query string — in real time. Set this as your webhook URL in Webhook Actions, submit the CF7 form, and inspect the exact JSON body that arrives. Compare the keys and values against what your downstream system expects. ### 2\. Test endpoint unavailability Temporarily set the webhook URL to a non-existent endpoint (e.g., `https://localhost:9999/test`) and submit the form. Check the Action Scheduler admin at `wp-admin/admin.php?page=action-scheduler` — you should see the job appear with status `failed` or `pending` (depending on timing). Verify the retry count increments on subsequent queue runner cycles. Then restore the real endpoint and confirm the job eventually delivers successfully. ### 3\. Verify field mapping Submit the form with known values in each field type (text, email, checkboxes, radio buttons, file upload if applicable) and compare the payload at webhook.site against the mapping you configured. Pay attention to checkbox arrays and any multi-value fields — these are the most common source of mapping surprises. ### 4\. Check delivery logs In the Webhook Actions admin, navigate to the delivery log for the CF7 trigger. Every delivery attempt shows the HTTP status code, response body excerpt, timestamp, and attempt number. A successful delivery shows status `200` (or whatever 2xx your endpoint returns). A failed attempt shows the status code and the beginning of the error response. This log is the primary debugging tool for any intermittent delivery issues. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Contact Form 7 Webhook: Send Submissions to Any URL","description":"Contact Form 7 webhook setup: the wpcf7_mail_sent hook, field mapping, and queued delivery with retries, so every submission reaches its endpoint.","datePublished":"2026-05-07","dateModified":"2026-05-07","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/contact-form-7-webhook/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["contact form 7 webhook","cf7 to webhook","cf7 webhook","contact form 7 to webhook plugin","wpcf7 before send mail","wordpress form webhook"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Contact Form 7 Webhook: Send Submissions to Any URL","item":"https://wpwebhooks.org/blog/contact-form-7-webhook/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does Contact Form 7 support webhooks natively?","acceptedAnswer":{"@type":"Answer","text":"No. Contact Form 7 does not include a built-in webhook sender. It provides the wpcf7_before_send_mail and wpcf7_mail_sent action hooks, which let developers add custom processing after a submission, but the webhook HTTP call must be built manually or via a plugin. Webhook Actions provides a no-code CF7 webhook trigger with field mapping, retry on failure, and delivery logs."}},{"@type":"Question","name":"What is the difference between wpcf7_before_send_mail and wpcf7_mail_sent?","acceptedAnswer":{"@type":"Answer","text":"wpcf7_before_send_mail fires before Contact Form 7 sends the email notification — you can modify the submission data or abort the mail here. wpcf7_mail_sent fires after the email has been sent successfully. For webhook dispatch, wpcf7_before_send_mail is typically used because it gives you access to the WPCF7_Submission object at its most complete state, though wpcf7_mail_sent is also valid if you want to confirm the email sent before triggering the integration."}},{"@type":"Question","name":"How do I get the submitted field values inside a CF7 hook?","acceptedAnswer":{"@type":"Answer","text":"Use WPCF7_Submission::get_instance() to get the current submission object, then call $submission->get_posted_data() to retrieve an associative array of field name to submitted value pairs. Field names correspond to the name attribute you set in the CF7 form editor — for example, [text* your-name] creates a field named 'your-name', accessed as $data['your-name']."}},{"@type":"Question","name":"Can I send CF7 data to n8n or Zapier without writing PHP?","acceptedAnswer":{"@type":"Answer","text":"Yes. Webhook Actions connects Contact Form 7 to any webhook URL — including n8n, Zapier, Make, and custom endpoints — without writing code. You install the plugin, configure a CF7 trigger, paste the webhook URL, and optionally map specific CF7 fields to payload keys. The plugin handles async dispatch, retry on failure, and delivery logging automatically."}},{"@type":"Question","name":"Why does my CF7 webhook work sometimes but not always?","acceptedAnswer":{"@type":"Answer","text":"Intermittent CF7 webhook failures are almost always caused by inline delivery: the wp_remote_post call inside the hook fires synchronously during the form submission PHP request. If the endpoint is momentarily slow, returning a 5xx error, or under load, the call times out or fails — and since there is no queue or retry, the submission is permanently lost. The fix is to move delivery out of the request and into a queued, asynchronous background job with retry logic."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/contact-form-7-webhook.png","caption":"When a CF7 form is submitted, wpcf7_before_send_mail fires and Webhook Actions enqueues the payload, letting the confirmation render immediately. In the background an Action Scheduler worker dispatches the webhook, logging delivery on a 2xx, rescheduling on 5xx or timeout, and dead-lettering after max retries.","description":"When a CF7 form is submitted, wpcf7_before_send_mail fires and Webhook Actions enqueues the payload, letting the confirmation render immediately. In the background an Action Scheduler worker dispatches the webhook, logging delivery on a 2xx, rescheduling on 5xx or timeout, and dead-lettering after max retries.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "gform_after_submission: Gravity Forms Hook Documentation" description: "gform_after_submission documentation: the exact hook signature, the argument count that breaks it, reading entry field IDs, and dispatching without blocking." url: "https://wpwebhooks.org/blog/gravity-forms-gform-after-submission-webhook/" date: "2026-05-07" --- # gform_after_submission: Gravity Forms Hook Documentation TL;DR - `gform_after_submission` fires after each successful Gravity Forms submission and provides both the `$entry` object and the `$form` array - Raw PHP `wp_remote_post` works in dev but loses events in production — any timeout or endpoint failure silently drops the submission - Webhook Actions enqueues the payload at hook time into its own database queue, dispatches it asynchronously via a background worker (triggered by Action Scheduler when available), and retries on failure with field mapping UI and per-attempt logs / The Hook ## What does **gform\_after\_submission** do? `gform_after_submission` is the action hook Gravity Forms fires after an entry has been saved to the database and all built-in processing — notifications, confirmations, payment actions — has completed. It is the correct place to trigger integrations that depend on the complete, saved entry record. Gravity Forms processes a submission in a predictable sequence. The form validates, the entry is written to `wp_gf_entry` and `wp_gf_entry_meta`, built-in notifications are dispatched, then `gform_after_submission` fires. By the time your callback runs, you have a fully resolved `$entry` array with every field value, an entry ID, a timestamp, and the source URL. Nothing is provisional. There is also a form-specific variant: `gform_after_submission_{form_id}`. If you want your callback to run only for form ID 3, hooking `gform_after_submission_3` removes the need for an `if ( $form['id'] === 3 )` conditional inside the callback. Both variants receive identical parameters. For the other hooks in the submission sequence — validation, pre-submission, confirmation — see the [Gravity Forms hooks reference](https://wpwebhooks.org/blog/gravity-forms-hooks-reference/). The hook is documented in the [official Gravity Forms developer documentation](https://docs.gravityforms.com/gform_after_submission/) and has been available since Gravity Forms 1.8. / Parameters ## What **parameters** does the hook pass? The hook passes two arguments: `$entry` and `$form`. The `add_action` call must specify the priority (10 is the default) and the accepted argument count (2) for both to arrive in your callback. Hook signature ``` add_action( 'gform_after_submission', function( $entry, $form ) { // $entry and $form are both available here }, 10, 2 ); ``` **`$entry`** is an associative array containing everything about the submitted entry: - `$entry['id']` — the numeric entry ID assigned by Gravity Forms - `$entry['form_id']` — the ID of the form that was submitted - `$entry['date_created']` — UTC timestamp of the submission - `$entry['source_url']` — the page URL where the form was embedded - `$entry['ip']` — submitter IP address - `$entry['1']`, `$entry['2']`, … — field values keyed by numeric field ID - `$entry['1.3']`, `$entry['1.6']`, … — sub-field values for multi-input fields (Name, Address, etc.) **`$form`** is the full form configuration array. It contains the form title, fields definition, notification settings, confirmation rules, and all Gravity Forms metadata. You rarely need the full `$form` object for webhook dispatch — the entry array contains everything you need to build a payload — but `$form['title']` and `$form['id']` are useful for routing logic. Use `rgar( $entry, $field_id )` instead of `$entry[ $field_id ]` directly. The `rgar()` helper function returns an empty string for missing keys rather than triggering a PHP notice, which matters when optional fields are not submitted. Accessing field values safely ``` add_action( 'gform_after_submission', function( $entry, $form ) { // Simple text field (field ID 1) $name = rgar( $entry, '1' ); // Email field (field ID 2) $email = rgar( $entry, '2' ); // First name sub-field of a Name field (field ID 3) $first = rgar( $entry, '3.3' ); $last = rgar( $entry, '3.6' ); // Entry metadata always present $entry_id = rgar( $entry, 'id' ); $created_at = rgar( $entry, 'date_created' ); $form_title = rgar( $form, 'title' ); }, 10, 2 ); ``` / Basic Implementation ## How do you send form data to a **webhook with raw PHP**? The minimal implementation hooks `gform_after_submission`, builds a payload array from the entry fields you need, and calls `wp_remote_post()` to deliver it. This pattern works in local development and for low-stakes, low-traffic forms where a dropped delivery is acceptable. functions.php — minimal gform\_after\_submission webhook ``` add_action( 'gform_after_submission_3', function( $entry, $form ) { $payload = [ 'entry_id' => rgar( $entry, 'id' ), 'form_title' => rgar( $form, 'title' ), 'name' => rgar( $entry, '1' ), 'email' => rgar( $entry, '2' ), 'message' => rgar( $entry, '3' ), 'submitted' => rgar( $entry, 'date_created' ), ]; $response = wp_remote_post( 'https://your-n8n-instance.example.com/webhook/gf-contact', [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $payload ), 'timeout' => 10, ] ); // No retry. No queue. No log. Event is gone if this fails. }, 10, 2 ); ``` This pattern produces a working webhook for simple cases. It is also the pattern that fails silently in production for reasons we cover in the next section. / Production Reality ## Why does the **raw PHP approach break** in production? The raw PHP implementation has four structural problems that only surface under production conditions. None of them are visible during local development. **1\. Inline execution blocks the user's form response.** `gform_after_submission` runs synchronously inside the PHP request that processed the form submission. The `wp_remote_post()` call holds the connection open until the endpoint responds or the timeout expires. On a slow n8n instance or a distant Zapier endpoint, the person who submitted the form waits for that HTTP call to complete before seeing the confirmation page. **2\. Timeout failures permanently lose the entry.** `wp_remote_post()` defaults to a 5-second timeout. Any endpoint that takes longer — including healthy endpoints under momentary load — results in a `WP_Error` with error code `http_request_failed`. There is no retry, no queue, and no log. The form entry was saved to Gravity Forms, but the downstream system never received it. **3\. Endpoint downtime causes permanent data loss.** If your n8n instance restarts, your Zapier webhook URL changes, or the receiving server returns a 5xx during a deployment, every form submission during that window is permanently lost. The raw PHP pattern has no mechanism to detect or recover from this. **4\. No visibility into failures.** WordPress does not log outbound `wp_remote_post()` calls by default. A failed webhook delivery produces no error in the WordPress admin, no PHP log entry (unless WP\_DEBUG\_LOG is enabled and the error is caught), and no alert. The failure is structurally silent. | Scenario | Raw PHP (wp\_remote\_post) | Webhook Actions (queued) | | --- | --- | --- | | Endpoint timeout (5s) | Entry lost permanently | Retried with backoff | | Endpoint 503 (server error) | Entry lost permanently | Retried up to 5 times | | PHP crash mid-request | Entry lost permanently | Queue row survives crash | | Endpoint down for 2 hours | All entries during window lost | Queue drains when endpoint recovers | | Delivery failure visibility | None | Per-attempt log with status codes | | User form experience | Blocks confirmation page | Instant — async dispatch | For a full analysis of how inline WordPress webhook delivery fails in production and the infrastructure required to fix it, see [Why WordPress Webhooks Silently Fail in Production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/). try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Webhook Actions ## How does **Webhook Actions** use gform\_after\_submission? Webhook Actions registers its own `gform_after_submission` listener internally. When a Gravity Forms submission triggers the hook, Webhook Actions does not attempt delivery immediately. Instead, it serializes the entry payload and creates a job in its own database queue. The form's confirmation page renders without waiting for any HTTP call. Webhook Actions' queue runner picks up the job on the next cycle — typically within a few seconds on a properly configured site — and dispatches the HTTP request from a background process completely isolated from the original form submission request. When Action Scheduler is available (e.g. WooCommerce is active), WA uses it to trigger its queue runner reliably; otherwise it falls back to WP-Cron. If the endpoint is unavailable, the job is rescheduled with exponential backoff and retried up to five times before marking the delivery as failed. Every delivery attempt — success or failure — is written to the Webhook Actions delivery log. From the WordPress admin you can see the HTTP status code, response body, attempt number, and timestamp for every job. Permanently failed deliveries can be manually retried from the log view without touching the codebase. The plugin configuration is entirely UI-based. You select the Gravity Forms trigger, choose the forms to listen on (all forms or a specific form by ID), configure the endpoint URL, and optionally define field mappings. No `add_action` code is required. See the [Gravity Forms Webhooks example](https://wpwebhooks.org/examples/gravity-forms-webhooks/) for the full step-by-step setup walkthrough. / Field Mapping ## How do you **map Gravity Forms fields** to webhook payload keys? By default, Webhook Actions sends the full Gravity Forms entry array as the webhook payload — every field keyed by its numeric Gravity Forms field ID. This is useful for development but produces a payload like `{"1": "John", "2": "john@example.com", "3": "Hello"}` that depends on specific field IDs remaining stable. Field mapping lets you define a stable, semantic payload structure that does not change when you rearrange fields in the Gravity Forms editor. In the Webhook Actions field mapping UI, you define key-value pairs where each value is a merge tag referencing a Gravity Forms field. Equivalent PHP mapping for reference ``` // This is what Webhook Actions does internally when field mapping is configured. // You do not write this code — the plugin generates it from the UI mapping. $payload = [ 'name' => rgar( $entry, '1.3' ) . ' ' . rgar( $entry, '1.6' ), 'email' => rgar( $entry, '2' ), 'phone' => rgar( $entry, '3' ), 'message' => rgar( $entry, '4' ), 'source_url' => rgar( $entry, 'source_url' ), 'entry_id' => rgar( $entry, 'id' ), 'submitted_at' => rgar( $entry, 'date_created' ), ]; ``` Multi-input fields require attention. Gravity Forms splits Name, Address, and Time fields into sub-inputs, each with a decimal field ID (`1.3` for first name, `1.6` for last name in a Name field). Webhook Actions exposes these as individual merge tags in the mapping UI. For a Name field with ID 1, the merge tags are `{Name (First):1.3}` and `{Name (Last):1.6}`. Checkboxes and multi-select fields store their values differently. Each checkbox option is a separate sub-input: checkbox field ID 5 with three options stores them at `5.1`, `5.2`, and `5.3`. The value is the option label if checked, or empty string if not. Webhook Actions provides a choice merge tag that concatenates all checked values as a comma-separated string for easier downstream handling. / File Uploads ## How do you **handle file upload fields**? Gravity Forms file upload fields store the uploaded file URL in the entry — not the file binary. The value at `$entry['5']` for a single-file upload field with ID 5 is the full public URL of the uploaded file, something like `https://yoursite.com/wp-content/uploads/gravity_forms/123-abc/file.pdf`. For multiple-file upload fields (Gravity Forms 2.6+), the entry value is a JSON-encoded array of URLs. You need to `json_decode()` it to work with individual URLs: Handling multi-file upload field values ``` // Single file upload — value is a URL string $file_url = rgar( $entry, '5' ); // Multi-file upload — value is a JSON array of URLs $raw_value = rgar( $entry, '6' ); $file_urls = json_decode( $raw_value, true ); // $file_urls is now [ 'https://...file1.pdf', 'https://...file2.pdf' ] ``` In a webhook payload, send file URLs rather than binaries. Downstream systems (n8n, Zapier, Make) can fetch the file contents from the URL if needed. Embedding binary data in a webhook payload body is impractical — it dramatically increases payload size, complicates JSON encoding, and most receiving endpoints do not expect it. One constraint to be aware of: Gravity Forms file uploads are stored in a publicly-accessible directory by default. If your forms contain sensitive files and you use the signed URL option, the URL in the entry may expire. Pass the URL immediately in the webhook payload rather than storing it for later use. Webhook Actions handles file upload fields by including the URL string (or JSON array string for multi-file) in the payload. The field mapping UI treats file upload fields like any other field. If you need to transform the multi-file JSON array into a clean array structure, configure a custom field mapping that uses the merge tag for the field — the plugin normalizes the output automatically. / Reliability ## What happens when **the endpoint is down**? This is the critical reliability question for production Gravity Forms webhook integrations, and the answer depends entirely on how delivery is implemented. **With raw PHP `wp_remote_post()`:** the delivery attempt is made inline during the form submission request. If the endpoint returns a 5xx, times out, or is unreachable, the error is silently discarded. There is no retry, no queue, and no recovery path. Every submission during the outage window is permanently lost. **With Webhook Actions:** the payload is serialized into WA's own database queue at hook time, before any HTTP attempt is made. The queue row survives PHP crashes, server restarts, and WordPress cache flushes. When the endpoint comes back online, WA's queue runner resumes delivery from where it stopped. The retry schedule uses exponential backoff. With a base delay of 60 seconds and a maximum of five attempts, a delivery that fails on attempt 1 is retried at 1 minute, 2 minutes, 4 minutes, 8 minutes, and 16 minutes — a total retry window of 31 minutes. For endpoints with longer outages, you can configure the maximum attempt count and backoff multiplier in the Webhook Actions settings. After all retry attempts are exhausted, the job is marked as failed in the Webhook Actions delivery log. You can view the full attempt history — including the HTTP status code and response body for each attempt — and manually re-trigger delivery from the WordPress admin without modifying any code. See the [Action Scheduler deep dive](https://wpwebhooks.org/blog/action-scheduler-wordpress/) for a full explanation of how the queue runner, concurrency settings, and failure states work. For critical integrations — CRM updates, payment notifications, or any downstream system where a missed event causes business impact — the difference between raw PHP and a queued delivery system is the difference between "sometimes works" and "reliably works." ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"gform_after_submission: Gravity Forms Hook Documentation","description":"gform_after_submission documentation: the exact hook signature, the argument count that breaks it, reading entry field IDs, and dispatching without blocking.","datePublished":"2026-05-07","dateModified":"2026-05-07","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/gravity-forms-gform-after-submission-webhook/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["gform after submission","gravity forms webhook","gravity forms after submission hook","gravity forms webhooks","wordpress form webhook"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"gform_after_submission: Gravity Forms Hook Documentation","item":"https://wpwebhooks.org/blog/gravity-forms-gform-after-submission-webhook/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does gform_after_submission fire for every Gravity Forms submission?","acceptedAnswer":{"@type":"Answer","text":"Yes. gform_after_submission fires on every successful Gravity Forms submission, regardless of which form triggered it. You can narrow to a specific form using the $form['id'] check inside the callback, or use the form-specific action gform_after_submission_{form_id} to avoid the conditional entirely."}},{"@type":"Question","name":"Can I stop the form submission from inside gform_after_submission?","acceptedAnswer":{"@type":"Answer","text":"No. gform_after_submission fires after the entry has already been saved to the database. To conditionally block a submission before it is recorded, use gform_pre_submission or gform_validation instead. gform_after_submission is strictly for post-processing — sending notifications, triggering integrations, dispatching webhooks."}},{"@type":"Question","name":"What is the difference between gform_after_submission and gform_entry_created?","acceptedAnswer":{"@type":"Answer","text":"gform_entry_created fires immediately after the entry row is inserted into the database, before Gravity Forms processes notifications and confirmations. gform_after_submission fires later in the same request, after all built-in processing is complete. For webhook dispatch, gform_after_submission is the safer choice because you have access to the fully processed $entry object with all field values resolved."}},{"@type":"Question","name":"How do I access a specific field value inside gform_after_submission?","acceptedAnswer":{"@type":"Answer","text":"Use rgar($entry, $field_id) where $field_id is the numeric Gravity Forms field ID as a string. For example, rgar($entry, '1') returns the value of field 1. For multi-input fields like Name or Address, use dot notation: rgar($entry, '1.3') returns the first name component of a Name field with ID 1. The rgar() helper handles missing keys gracefully and returns an empty string if the key does not exist."}},{"@type":"Question","name":"Why does my gform_after_submission webhook work locally but not in production?","acceptedAnswer":{"@type":"Answer","text":"The hook fires correctly in both environments — the delivery failure is almost always in the wp_remote_post call that follows. In production, the target endpoint may be slow, temporarily unavailable, or returning errors. Without a persistent queue and retry logic, any failure during the synchronous HTTP call is permanently lost. The solution is to enqueue the payload at hook time and dispatch it asynchronously via a background job with retry — which is what Webhook Actions does with its own database queue, using Action Scheduler to trigger the runner when available."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/gravity-forms-gform-after-submission-webhook.png","caption":"A Gravity Forms submission fires gform_after_submission, and Webhook Actions enqueues the payload so the confirmation renders immediately. The WA queue runner then delivers the webhook, logging completion on a 2xx, rescheduling on 5xx or timeout for up to five retries, and logging a permanent failure on a 4xx.","description":"A Gravity Forms submission fires gform_after_submission, and Webhook Actions enqueues the payload so the confirmation renders immediately. The WA queue runner then delivers the webhook, logging completion on a 2xx, rescheduling on 5xx or timeout for up to five retries, and logging a permanent failure on a 4xx.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "From do_action to HTTP: WordPress Webhook Delivery" description: "How WordPress webhook plugins discover hooks, register listeners, and build delivery payloads. Engineering internals with real code." url: "https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/" date: "2026-04-22" --- # From do_action to HTTP: WordPress Webhook Delivery TL;DR - Hook discovery combines `$wp_filter` runtime scanning with static PHP source analysis — neither approach alone covers all available hooks - Payload serialization converts WP\_Post, WC\_Order, and custom PHP objects into flat JSON, normalizing nested objects and discarding non-serializable properties - The full delivery path: `do_action` → hook listener → payload normalization → queue dispatch → async HTTP worker → delivery log entry / Hook Discovery ## Why isn't **$wp\_filter alone** enough for hook discovery? The first challenge any webhook plugin faces: **how do you show users a useful, accurate list of every hookable event on their specific WordPress install?** The obvious answer is to read the global `$wp_filter` array — the data structure that `add_action()` and `add_filter()` write to, which contains every hook that has at least one registered callback at that moment. This works for a small handcrafted list of common hooks, but it breaks down as soon as users want to use hooks from plugins like WooCommerce. The core issue is **WordPress request context**: when the admin UI calls `GET /fswa/v1/triggers` (a WP REST API request), WordPress bootstraps in REST mode. Many plugins register their hooks conditionally — only during front-end requests, only during `admin-ajax.php`, or only when a specific page template loads. A hook like `woocommerce_order_status_changed` is registered by WooCommerce during the order processing lifecycle, which never runs during a REST API call. So `$wp_filter` during that request simply doesn't contain it. The same problem applies to hooks from form plugins, membership plugins, and anything that gates its `add_action()` calls behind `is_admin()`, `!is_admin()`, or specific action priorities. WooCommerce is a particularly important case to cover. According to [BuiltWith e-commerce technology data](https://trends.builtwith.com/shop/WooCommerce), WooCommerce powers roughly a quarter of all online stores globally — making its hooks among the most business-critical events any WordPress site fires, and missing them from the discovery list would be a significant gap for most users. The original workaround — a hardcoded `getSuggestedTriggers()` list of ~250 manually curated hooks — was immediately stale and said nothing about what was actually installed on the user's site. The scale of the problem becomes clear from the numbers: [WordPress.org](https://wordpress.org/plugins/) lists over 59,000 plugins in its repository, each potentially registering its own set of hooks. No handcrafted list could cover that breadth — let alone stay current as plugins are updated and new ones appear. ### Static scan: HookDiscoveryService The solution is to combine what WordPress _can_ tell us at request time with what the source code _guarantees_ will fire at runtime. `HookDiscoveryService.php` does a one-time recursive scan of all active plugins (resolved to filesystem paths via `WP_PLUGIN_DIR`), WordPress core (`wp-includes/` and `wp-admin/`), and the active theme and child theme. For each PHP file it runs a single regex over the raw source: HookDiscoveryService.php — static hook extraction regex ``` preg_match_all( '/do_action(?:_ref_array)?s*(s*['"]([a-zA-Z0-9_-./]+)['"]/', $content, $matches ); ``` This intentionally only matches **string literal** hook names. Dynamic hook names like `do_action( "save_post_{$post->post_type}" )` are not captured — they cannot be statically known. The trade-off is conservative and noise-free; users can always type a custom hook name in the UI. The regex was extended once in development (`/` was added to the character class) when IvyForms integration was added — its hooks follow a `namespace/event/name` convention like `ivyforms/form/after_submission`, and the original pattern didn't allow forward slashes. `RecursiveCallbackFilterIterator` skips `vendor/`, `node_modules/`, and `.git` directories inside each plugin folder. The plugin's own files are excluded from the scan to avoid false positives. ### Caching and cache invalidation Scanning an entire WordPress install is not free. Results are stored in a WordPress transient (`fswa_discovered_hooks_v3`) with a 24-hour TTL. The cache is busted immediately on plugin activation, deactivation, and theme switches: cache invalidation hooks ``` add_action('activated_plugin', [HookDiscoveryService::class, 'clearCache']); add_action('deactivated_plugin', [HookDiscoveryService::class, 'clearCache']); add_action('switch_theme', [HookDiscoveryService::class, 'clearCache']); ``` The cache key went through three versions (`_v1`, `_v2`, `_v3`) as the return format changed between releases. Bumping the key forces a clean rebuild on existing installs without a migration step — old transient values are simply ignored. The scanner returns `array` — the hook name as key, the plugin directory name (e.g. `contact-form-7`) or `wordpress` for core as value. First plugin to define a hook wins on conflicts. / Runtime Merge ## How do you combine **two sources of truth** for complete hook coverage? Neither `$wp_filter` nor the static scan alone is complete. The solution is to use both and merge them. `TriggersController.php` serves `GET /fswa/v1/triggers` and combines both sources into a single grouped response: 1. Iterate `$wp_filter` keys, apply an exclusion filter, and bucket each hook into a category. 2. Run `HookDiscoveryService::discover()` and merge any hook not already seen. 3. Return `{ grouped: { category: [hookName, …] }, categories: { key: label }, allowCustom: true }`. ### Exclusion filter Hooks that are technically `do_action` calls but are useless as webhook triggers get dropped before reaching the UI. These are UI-only exclusions — they don't affect which hooks the plugin will intercept at runtime. A user can still type any hook name manually. TriggersController.php — exclusion patterns ``` $excludePatterns = [ '/^admin_/', // admin page hooks '/^wp_ajax/', // ajax handler hooks '/^rest_api/', // REST framework hooks '/^wp_enqueue/', // asset enqueueing '/^sanitize_/', // sanitization filters '/^pre_get/', // pre-query filters '/^get_/', // getter filters ]; ``` ### Category assignment Runtime hooks from `$wp_filter` are bucketed by name-pattern matching — `woocommerce` catches anything starting with `/^woocommerce/`, `users` catches `/user|login|logout|password|role|profile/`, and so on. Statically-scanned hooks from plugins are bucketed under their plugin slug. All Contact Form 7 hooks go under `contact_form_7`, displayed as "Contact Form 7". Third-party plugin hooks self-group with no hardcoded mapping required. An earlier version of this API returned ~643 KB of per-hook metadata per request. The current version returns only names grouped by category; labels are computed client-side from the category key. On a site with WooCommerce and several form plugins active, the response now sits at **83.7 kB covering ~2,500 discovered `do_action` calls** — a roughly 8× reduction with no loss of information for the UI. The breadth of that coverage matters at scale. [W3Techs web technology surveys](https://w3techs.com/technologies/details/cm-wordpress) report that WordPress powers over 43% of all websites on the internet — a webhook plugin that misses conditionally-registered plugin hooks fails across a significant share of production installs. / Dynamic Registration ## Listening on hooks **at boot time** Once a user saves a webhook with a trigger set to (say) `wpcf7_before_send_mail`, the plugin needs to listen on that exact action. `HooksHandler.php` does this at boot time by reading all configured trigger names and registering a listener for each: HooksHandler.php — boot-time registration loop ``` $allTriggers = $this->dispatcher->getWebhooksRepository()->getAllTriggers(); foreach ($allTriggers as $trigger) { add_action($trigger, [$this, 'registerTriggerHandler'], 10, PHP_INT_MAX); } ``` `PHP_INT_MAX` as the accepted argument count means the handler receives every argument WordPress passes to the hook, regardless of arity. Without it, a hook that passes three arguments would only deliver the first one to the handler — the default for `add_action` is 1. ### Deduplication WordPress can fire the same hook multiple times in a single request — `save_post` fires on autosave, on the main save, and sometimes on meta updates. Delivering duplicate webhooks for the same event is a data quality problem for the receiver. Two deduplication layers guard against this: **In-process static lock** — catches duplicate calls within the same PHP execution: in-process deduplication ``` static $triggerLocks = []; $uniqueKey = md5($trigger . serialize($args)); if (isset($triggerLocks[$uniqueKey])) { return; } $triggerLocks[$uniqueKey] = true; ``` **Cross-request transient lock (30 seconds)** — catches the case where Action Scheduler or WP-Cron reschedules a job while the original request is still processing: cross-request transient lock ``` $transientKey = 'fswa_trigger_' . md5($trigger . serialize($args)); if (get_transient($transientKey)) { return; } set_transient($transientKey, true, 30); ``` / Payload Construction ## Building the **delivery envelope** Every webhook delivery gets a consistent envelope, constructed in `Dispatcher::dispatch()`: Dispatcher.php — base payload shape ``` $payload = apply_filters('fswa_payload', [ 'event' => [ 'id' => $eventUuid, // wp_generate_uuid4() 'timestamp' => $eventTimestamp, // gmdate ISO 8601 'version' => '1.0', ], 'hook' => $trigger, 'args' => $this->normalizeArgs($args), 'timestamp' => time(), 'site' => ['url' => home_url()], ], $trigger, $args); ``` `$eventUuid` is generated once per `dispatch()` call and shared across all webhooks subscribed to the same trigger. If five webhooks listen on `woocommerce_order_status_changed`, they all carry the same `event.id`. This lets the receiving end deduplicate or correlate deliveries without needing to match payloads. The UUID and timestamp are also sent as HTTP headers on every delivery: `X-Event-Id` and `X-Event-Timestamp`. ### The serialization problem: normalizeArgs WordPress hook arguments are untyped PHP values. They can be scalars, arrays, closures, objects, or any combination. `normalizeArgs` walks the argument list and applies type-specific handling: **Scalars** — pass through directly as JSON-serializable values. **Arrays** — recursed with the same normalization applied to each value. **Closure** — not serializable; replaced with `null`. **DateTimeInterface** — serialized to ISO 8601 atom string. **Traversable** — iterated to a plain array. **JsonSerializable** — `jsonSerialize()` is called. **Objects with `get_data()`** — WooCommerce `WC_Order`, `WC_Product`, etc. follow this convention. **Objects with `get_properties()`** — some WP core objects. **Everything else** — `get_object_vars()` as a last resort. Every serialized object gets a `__type` key set to `get_class($value)`: \_\_type annotation on serialized objects ``` return array_merge(['__type' => get_class($value)], $data); ``` Without `__type`, two different object classes with identical field names would be indistinguishable on the receiving end. It also serves as documentation: the receiver knows exactly which WooCommerce or WordPress class produced each argument in the payload. A filter escape hatch lets third-party code override normalization for specific classes before the default handling runs: fswa\_normalize\_object filter — third-party override point ``` $custom = apply_filters('fswa_normalize_object', null, $value); if (is_array($custom)) { return array_merge( ['__type' => get_class($value)], array_map([$this, 'normalizeValue'], $custom) ); } ``` / Third-Party Integrations ## Normalizing **plugin-specific objects** Generic reflection is fine for simple objects but produces unusable noise for complex CMS entities. The plugin ships built-in integrations for plugins whose hook arguments would otherwise be unreadable. ### Contact Form 7 CF7 fires three hooks around mail sending: `wpcf7_before_send_mail`, `wpcf7_mail_sent`, and `wpcf7_mail_failed`. The problem is their `$args` signatures are not consistent — `wpcf7_mail_sent` and `wpcf7_mail_failed` only pass a `WPCF7_ContactForm` object. There is no submission data anywhere in `$args`. Without an integration, the webhook payload would contain form metadata but none of the fields the user actually submitted. The fix relies on the fact that `WPCF7_Submission` is a singleton — it stays alive in memory for the duration of the request. Even when the hook only passes the form object, `WPCF7_Submission::get_instance()` returns the active submission. The integration verifies the singleton's form ID matches before pulling it in, so it never attaches a stale submission from a different form: CF7Integration.php — pulling submission data from the singleton ``` private function normalizeContactForm(WPCF7_ContactForm $form, bool $includeSubmission = true): array { $data = [ 'id' => $form->id(), 'title' => $form->title(), 'name' => $form->name(), 'locale' => $form->locale(), ]; if ($includeSubmission) { $submission = WPCF7_Submission::get_instance(); if ($submission && $submission->get_contact_form()->id() === $form->id()) { $data['submission'] = $this->normalizeSubmission($submission); unset($data['submission']['form']); // avoid redundant nesting } } return $data; } ``` The `$includeSubmission = false` flag prevents recursion: when `normalizeSubmission` calls back into `normalizeContactForm` to embed the form reference, that inner call skips the singleton lookup. Internal CF7 fields (keys starting with `_`) are stripped from posted data before they reach the payload — so fields like `_wpcf7`, `_wpcf7_version`, and `_wpcf7_unit_tag` never appear in the webhook. The final payload shape for a `wpcf7_mail_sent` event contains `{ id, title, name, locale, submission: { fields: {...}, meta: { url, timestamp, remote_ip, … }, uploaded_files: […] } }` — the same shape regardless of whether the hook passed the submission directly or whether it was retrieved from the singleton. ### IvyForms IvyForms submission hooks (`ivyforms/form/before_submission`, `ivyforms/form/after_submission`) pass four arguments: `(int $formId, array $submissionData, Field[] $formFields, int|null $entryId)`. The problem is that `$submissionData` is keyed by _numeric field ID_, while `$formFields` is an array of typed `Field` entity objects. A receiver getting the raw payload would need to cross-reference two separate arrays — matching numeric IDs to labels — to understand what was submitted. The integration registers two filters. First, `fswa_normalize_object` converts each `Field` entity in `args[2]` to `{ id, label, type, required }`. Then `fswa_payload` runs after normalization and injects the submitted value from `args[1]` into each already-normalised field entry: IvyFormsIntegration.php — injecting submitted values into field definitions ``` public function transformSubmissionPayload(array $payload, string $trigger): array { if (!in_array($trigger, self::SUBMISSION_HOOKS, true)) { return $payload; } $submissionData = $payload['args'][1] ?? []; foreach ($payload['args'][2] as &$fieldData) { $fieldId = $fieldData['id'] ?? null; if ($fieldId === null) { continue; } // submissionData may be keyed by int or string ID — try both $fieldData['value'] = $submissionData[(string) $fieldId] ?? $submissionData[$fieldId] ?? null; } unset($fieldData); return $payload; } ``` The double lookup — `(string) $fieldId` first, then `$fieldId` as-is — handles the fact that PHP array keys may be stored as integers or strings depending on how IvyForms built the submission array. The result: `args[2]` in the final payload is a single self-contained array of `{ id, label, type, required, value }` entries, with no separate lookup needed by the receiver. ### The slash problem IvyForms hook names contain forward slashes: `ivyforms/form/after_submission`. This caused two independent bugs that both had to be fixed separately: **Bug 1 — Discovery regex dropped them.** The original character class `[a-zA-Z0-9_-.]` did not include `/`, so these hooks were never found by the static scan. Fix: add `/` to the character class. **Bug 2 — REST API 404s.** Hook names with slashes were passed as literal URL path segments (`/fswa/v1/schemas/ivyforms/form/after_submission`), causing WordPress to fail route matching. Fix: `encodeURIComponent()` on the client side; on the server, expand the route regex to accept `%` and `.`, then `rawurldecode()` before `sanitize_text_field()` to recover the original hook name. / Field Mapping ## Reshaping payloads with **dot-notation paths** Users can reshape the payload before it is sent: rename fields, exclude noise, move nested values to the root. The mapping config is stored in the schema table: field mapping configuration — stored JSON ``` { "mappings": [ { "source": "args.0.billing_email", "target": "email" } ], "excluded": ["args.0.customer_note"], "includeUnmapped": true } ``` `PayloadTransformer::applyFieldMapping()` first flattens the entire payload to dot-notation paths, applies explicit mappings, then optionally carries over everything else when `includeUnmapped` is true. ### The Gravity Forms sub-field problem Gravity Forms uses numeric sub-field IDs with dots in the key name: field `6.1`, `6.2`, etc. A key `6.1` in a PHP array, when represented in dot notation, is ambiguous — is this key `6` → key `1`, or key `6.1`? The fix: dot characters inside array keys are escaped as `.` when building paths. `splitPath()` uses a negative lookbehind to split only on unescaped dots: PayloadTransformer.php — splitPath with escaped dot support ``` $parts = preg_split('/(? str_replace('.', '.', $p), $parts); ``` Path `args.0.6.1` splits into `['args', '0', '6.1']` — the segment `6.1` is treated as a single key. Without this, Gravity Forms sub-fields would silently produce incorrect path resolution. / Architecture ## The full **delivery pipeline** | Decision | Reason | | --- | --- | | Static scan + runtime merge | Neither source alone is complete; union gives best coverage | | | Regex matches string literals only | Dynamic hook names can't be statically known; conservative is better than noisy | | | PHP\_INT\_MAX accepted args | Hooks pass varying argument counts; we need all of them | | | Event UUID shared per dispatch | Allows correlation across multiple webhooks triggered by the same event | | | \_\_type on serialized objects | Receiver needs type context; opaque blobs are useless | | | fswa\_normalize\_object filter | Keeps integrations decoupled; third-party plugins can ship their own normalizer | | | Escaped dots in path notation | Gravity Forms-style fractional field IDs (6.1) break naïve dot-split | | | Transient cache key versioning | Format changes invalidate existing caches without a migration step | | try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Production Plugin ## If you'd rather not **build this yourself** **Flow Systems Webhook Actions** is an open-source WordPress plugin that implements this entire pipeline — hook discovery, dynamic registration, async queue delivery, payload normalization, field mapping, and structured logs. The [async delivery architecture](https://wpwebhooks.org/blog/async-webhooks-wordpress/) and [REST API control layer](https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/) are covered in separate articles. The plugin is free, GPL-licensed, and available on [WordPress.org](https://wordpress.org/plugins/flowsystems-webhook-actions/) and [GitHub](https://github.com/flowsystems-pl/wordpress-webhook-actions). Full details on the [WordPress webhook plugin page](https://wpwebhooks.org/wordpress-webhook-plugin/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"From do_action to HTTP: WordPress Webhook Delivery","description":"How WordPress webhook plugins discover hooks, register listeners, and build delivery payloads. Engineering internals with real code.","datePublished":"2026-04-22","dateModified":"2026-04-22","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/","image":"https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/og_image.jpg","keywords":["wordpress webhook","do action webhook","wordpress hook discovery","wordpress payload serialization","webhook plugin internals"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"From do_action to HTTP: WordPress Webhook Delivery","item":"https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why doesn't $wp_filter contain all WordPress hooks?","acceptedAnswer":{"@type":"Answer","text":"$wp_filter is a runtime data structure — it only contains hooks that have at least one registered callback at the moment it is read. Many plugins register their hooks conditionally: only during front-end requests, only during admin-ajax.php, or only when a specific page template loads. A REST API request bootstraps WordPress in REST mode, so plugins that gate their add_action() calls behind is_admin() or specific lifecycle events never register their hooks during that request. $wp_filter at REST API time is an accurate snapshot of that context, not an exhaustive list of every hookable event on the site."}},{"@type":"Question","name":"How does WordPress webhook payload serialization handle complex PHP objects like WooCommerce orders?","acceptedAnswer":{"@type":"Answer","text":"WordPress hook arguments are untyped PHP values. A normalizeArgs function walks the argument list and applies type-specific handling: scalars pass through directly, arrays are recursed, Closures become null, DateTimeInterface objects are converted to ISO 8601 strings, JsonSerializable objects call jsonSerialize(), and WooCommerce-style objects with get_data() or get_properties() methods call those. Everything else falls back to get_object_vars(). Every serialized object gets a __type key set to get_class() so the receiving end knows what it is looking at. A fswa_normalize_object filter lets third-party code override the default handling for specific classes."}},{"@type":"Question","name":"Why does WordPress hook discovery use a regex instead of PHP reflection or token_get_all()?","acceptedAnswer":{"@type":"Answer","text":"Reflection operates on loaded classes — it cannot enumerate hooks that are registered conditionally or in files that were never included during the current request. token_get_all() is correct but much slower across thousands of PHP files in core and all active plugins. A single regex run over raw file contents is fast enough for a one-time scan of an entire WordPress install (cached for 24 hours) and conservative by design: it only matches string literal hook names, deliberately ignoring dynamic names like do_action( 'save_post_' . $post_type ) that cannot be statically known. The trade-off is an occasional false negative that the user can cover by typing the hook name manually."}},{"@type":"Question","name":"What does the __type field in webhook payloads mean?","acceptedAnswer":{"@type":"Answer","text":"When a PHP object is serialized into the webhook payload, its class name is stored under the __type key alongside the object's data. For example, a WooCommerce order argument becomes { \\"}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-webhook-delivery-engineering.png","caption":"A do_action fires the HooksHandler, which dispatches through payload building, the fswa_payload filter, transformation, condition evaluation, pending logging, and enqueue, then sends over HTTP and reschedules or fails. Separately, the admin hook-discovery endpoint scans live filters, active plugins, core, and the theme with a 24-hour cache.","description":"A do_action fires the HooksHandler, which dispatches through payload building, the fswa_payload filter, transformation, condition evaluation, pending logging, and enqueue, then sends over HTTP and reschedules or fails. Separately, the admin hook-discovery endpoint scans live filters, active plugins, core, and the theme with a 24-hour cache.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Cron Job: Why WP-Cron Fails (And How to Fix)" description: "WordPress cron job not running? WP-Cron only fires on page load. Replace it with a real system cron in two lines — full setup with WP-CLI and Action Scheduler." url: "https://wpwebhooks.org/blog/cron-job-for-wordpress/" date: "2026-03-30" --- # WordPress Cron Job: Why WP-Cron Fails (And How to Fix) TL;DR - WP-Cron is not a real scheduler — it runs inside a WordPress page load and only fires when someone visits the site, making it unreliable for background tasks - Replace it with a real system cron entry: `*/5 * * * * curl -s https://yoursite.com/wp-cron.php?doing_wp_cron` and disable WP-Cron in `wp-config.php` - For complex job management, Action Scheduler (bundled with WooCommerce) provides persistent, queryable background processing built on top of a database queue / How It Works ## Why is WP-Cron a **pseudo-scheduler**, not a daemon? A real cron job is a system-level daemon — `crond` on Linux — that wakes up on a fixed schedule, independent of application traffic. WordPress has no equivalent. What it has is **WP-Cron**: a PHP-based scheduling system that runs inside a normal HTTP request, only when someone visits the site. The mechanics are straightforward. Every time WordPress bootstraps — on every non-AJAX, non-admin page load — it calls `spawn_cron()`. That function checks whether any scheduled events are due by reading the `cron` option from `wp_options`. If events are overdue, WordPress fires a non-blocking HTTP request to `/wp-cron.php` in the background and continues serving the page. The cron request runs in a separate PHP process, executes due events, and exits. For the full function-by-function reference — `wp_doing_cron()`, `spawn_cron()`, `DISABLE_WP_CRON`, and the `wp cron event run` WP-CLI commands — see the [WP-Cron Developer Reference](https://wpwebhooks.org/blog/wp-cron-developer-reference/). Scheduled events are stored as a serialised array in `wp_options` under the key `cron`. Each entry maps a Unix timestamp (the next run time) to an array of hook names, and each hook name to its schedule interval and arguments. You can inspect this directly: WP-CLI — inspect scheduled events ``` # List all scheduled events with next run time and interval wp cron event list # Show all registered schedules (hourly, twicedaily, daily, plus any custom ones) wp cron schedule list # Inspect the raw _cron option wp option get cron ``` You can also check via PHP. `wp_get_scheduled_event( 'my_hook' )` returns the next scheduled instance of a hook — or `false` if it is not scheduled. `wp_next_scheduled( 'my_hook' )` returns the Unix timestamp of the next run, or `false`. The lock — a transient named `doing_cron` — is set at the start of the cron run and expired automatically. Its purpose is to prevent two simultaneous page loads from each spawning a cron process that runs the same events twice. As we will see, this protection is weaker than it appears. The WordPress developer documentation acknowledges these limitations directly. The [WordPress Plugin Handbook section on hooking WP-Cron into system task schedulers](https://developer.wordpress.org/plugins/cron/hooking-wp-cron-into-the-system-task-scheduler/) recommends disabling the page-load trigger with `DISABLE_WP_CRON` and replacing it with a real system cron entry — particularly for any site where consistent task execution matters. / The Problem ## Why WP-Cron **fails silently** in production The traffic dependency is the most obvious issue: if nobody visits your WordPress site, scheduled events do not run. A plugin that sends a digest email at 08:00 every morning will not send if traffic to the site is zero at that moment. On low-traffic sites — internal tools, staging environments, B2B portals with off-hours downtime — this is not a theoretical edge case. It is the default outcome. But traffic-dependent execution is not the only failure mode. There are four others that catch developers by surprise: **Race conditions under concurrent load.** The transient-based lock assumes that only one process will check and set the lock at the same time. Under traffic spikes, two simultaneous requests can both read an unlocked state before either writes the lock. Both spawn cron processes; both run the same events. For idempotent tasks this is a nuisance. For tasks that send emails, charge cards, or write external records, it is a bug. **DISABLE\_WP\_CRON set with no replacement.** Many managed WordPress hosts pre-set `define( 'DISABLE_WP_CRON', true );` in `wp-config.php`. Some configure a system cron replacement. Many do not, or they do and then you migrate to a new server. The result: the constant silently disables WP-Cron's traffic-triggered mechanism, and nothing takes its place. No events run. No errors appear. Scheduled jobs accumulate in the `_cron` option with timestamps that grow increasingly stale. Not every host behaves the same way — [WP Engine, for example, leaves cron traffic-dependent until you enable its Alternate Cron service](https://wpwebhooks.org/blog/wp-engine-cron-jobs/). **PHP time limits cutting events short.** WP-Cron runs inside a PHP process, subject to `max_execution_time`. If a single cron event takes longer than the remaining time budget, PHP kills the process. The event may be partially executed, or subsequent events in the same run may never start. Because the exception is caught at the server level rather than in WordPress, no error is written to the WordPress debug log. **Slow requests suppressing cron entirely.** `spawn_cron()` uses a transient timeout to rate-limit how often a new cron process is spawned — by default, at most once every 60 seconds. If a slow page request triggers a cron spawn and the cron process runs for 90 seconds, the next page load within that window will see the transient still set and skip spawning. On sites with slow cron events and moderate traffic, this can cause events to fire far less frequently than their registered schedule. WP-Cron is a polite fiction: it works well enough on small, consistent-traffic sites where the cost of an occasional missed event is low. In production environments — especially those running background job queues, scheduled notifications, or webhook dispatch workers — it is not a reliable foundation. Action Scheduler, the background processing library that ships with WooCommerce, was created specifically to address WP-Cron's limitations at scale. According to the [Action Scheduler documentation](https://actionscheduler.org/), it is now used by over 100 WordPress plugins as a reliable alternative to WP-Cron for high-volume, high-throughput scheduling — with database-backed job storage, concurrency control, and built-in failure handling. / Comparison ## How does WP-Cron compare to System Cron and **Action Scheduler**? | Aspect | WP-Cron (default) | System Cron | Action Scheduler | | --- | --- | --- | --- | | Trigger mechanism | HTTP requests (traffic-dependent) | System daemon (time-based) | System daemon + internal queue runner | | Reliability | Skips on zero traffic | Runs on schedule, always | Runs on schedule, recovers from PHP crashes | | Parallel safety | Race condition under load | Single process (safe but sequential) | Locks per-action, parallel batch runners | | Job persistence | Lost if PHP process dies mid-run | Lost if PHP process dies mid-run | Persisted in DB; survives PHP crashes | | Failure visibility | Silent — no error log by default | System logs only | Per-action status, error messages, admin UI | | Retry on failure | None | None (must be implemented manually) | Built-in; configurable attempts | | Scalability | Sequential, single PHP thread | Sequential, single PHP thread | Concurrent batch processing | | Setup complexity | Zero (built into WordPress) | Low (one crontab entry) | Requires Composer or WooCommerce | The choice is not always obvious. A system cron job calling `wp-cron.php` is the right fix for the majority of production sites — it is simple, reliable, and requires no new dependencies. Action Scheduler becomes the right answer when you need persistent job storage, failure tracking, or parallel processing of variable-length queues. / Setup ## Disabling WP-Cron and configuring a **real cron job** Configuring a reliable cron job for WordPress is a two-step process. First, tell WordPress to stop piggybacking cron on page loads. Second, configure a system cron job to call `wp-cron.php` on a fixed schedule. **Step 1 — disable traffic-triggered execution.** Add the following constant to `wp-config.php`, above the line that reads `/* That's all, stop editing! */`: wp-config.php — disable traffic-triggered WP-Cron ``` /** * Disable WP-Cron's page-load-triggered execution. * You MUST configure a system cron job alongside this constant, * or no scheduled events will run at all. */ define( 'DISABLE_WP_CRON', true ); ``` **Step 2 — add a system cron entry.** Open the crontab for the user that runs your web server (or the www-data / nginx / apache user, depending on your server configuration): crontab — run WP-Cron every minute via wget ``` # Run WordPress scheduled events every minute * * * * * wget -q -O - https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1 # Alternative: use curl instead of wget * * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron &>/dev/null ``` Replace `https://example.com` with your actual site URL. The `doing_wp_cron` query parameter tells WordPress that this is an intended cron request, not a regular page load, so it skips the lock check and runs all due events immediately. One minute is the standard interval. WordPress's internal scheduler can only meaningfully schedule events at intervals that are a multiple of the cron trigger frequency — so triggering every minute gives you the finest granularity available. **WP-CLI alternative.** On servers where outbound HTTP from the cron user is blocked (or you prefer not to add HTTP overhead), WP-CLI provides a direct alternative: crontab — run WP-Cron via WP-CLI (no HTTP) ``` # Run due events directly via WP-CLI (no HTTP request overhead) * * * * * cd /var/www/html && wp cron event run --due-now --path=/var/www/html --url=https://example.com --quiet # Or with an absolute path to the wp binary * * * * * /usr/local/bin/wp cron event run --due-now --path=/var/www/html --allow-root --quiet ``` WP-CLI's approach bootstraps WordPress directly in the CLI process rather than making an HTTP call. It is faster and avoids any firewall or TLS certificate issues that might block the `wget`/`curl` approach. The trade-off is that WP-CLI must be installed and the correct PHP binary must be available in the cron environment. In containerised environments (Docker, Kubernetes), the recommended pattern is to run a separate container or sidecar that executes `wp cron event run --due-now` on a fixed interval, targeting a shared filesystem mount. This keeps cron execution separate from your web-serving containers and allows independent scaling. / Custom Events ## Registering and scheduling **custom cron events** Custom scheduled events follow a consistent pattern: register a custom schedule interval if you need something other than the built-in `hourly`, `twicedaily`, and `daily`; schedule the event on plugin activation with a guard to prevent double-scheduling; bind a callback to the event hook; and clean up on deactivation. plugin.php — register a custom every-5-minutes cron event ``` /** * 1. Register a custom schedule interval. * Hook: cron_schedules (filter) */ add_filter( 'cron_schedules', function( $schedules ) { $schedules['every_five_minutes'] = [ 'interval' => 300, // seconds 'display' => 'Every 5 Minutes', ]; return $schedules; } ); /** * 2. Schedule the event on plugin activation. * Guard with wp_next_scheduled() to avoid duplicate entries. */ register_activation_hook( __FILE__, function() { if ( ! wp_next_scheduled( 'my_plugin_process_queue' ) ) { wp_schedule_event( time(), // first run: now 'every_five_minutes', // schedule name 'my_plugin_process_queue' // hook name ); } } ); /** * 3. Clear the event on plugin deactivation. */ register_deactivation_hook( __FILE__, function() { $timestamp = wp_next_scheduled( 'my_plugin_process_queue' ); if ( $timestamp ) { wp_unschedule_event( $timestamp, 'my_plugin_process_queue' ); } } ); /** * 4. Bind the callback to the scheduled hook. */ add_action( 'my_plugin_process_queue', function() { // Read pending jobs from your queue table and process them. $jobs = my_plugin_get_pending_jobs( 25 ); // batch of 25 foreach ( $jobs as $job ) { my_plugin_dispatch( $job ); } } ); ``` A few things worth being deliberate about here. The `wp_next_scheduled()` guard in the activation hook is not optional — without it, repeated plugin deactivations and reactivations will accumulate duplicate scheduled events in the `_cron` option, each firing independently. The callback will run multiple times per interval. The deactivation hook must use `wp_unschedule_event()` with the actual timestamp, not just the hook name. `wp_clear_scheduled_hook( 'my_plugin_process_queue' )` is a simpler alternative that removes all instances of a hook regardless of timestamp — it is the safer choice if you are not certain whether one or multiple instances have been scheduled. Keep the callback fast. WP-Cron (and even system cron) executes the callback synchronously within the PHP process. If you have a large queue, process it in batches across multiple cron runs rather than attempting to drain it entirely in one execution — PHP time limits apply regardless of how cron is triggered. / Action Scheduler ## When WP-Cron isn't enough: **Action Scheduler** Action Scheduler is a background job library for WordPress, originally developed by Automattic for WooCommerce and now used by WooCommerce Subscriptions, WooCommerce Payments, and dozens of other major plugins. It is available as a standalone Composer package (`woocommerce/action-scheduler`) and ships bundled with WooCommerce. The key difference from WP-Cron is that Action Scheduler stores jobs in a dedicated database table rather than the `_cron` option. This gives you: durable storage that survives PHP crashes, per-job status tracking (`pending`, `in-progress`, `complete`, `failed`, `canceled`), built-in retry logic, and a UI under **WooCommerce → Status → Scheduled Actions** (or directly at `/wp-admin/admin.php?page=action-scheduler`). The API is straightforward: action-scheduler-usage.php — schedule and handle deferred actions ``` /** * Schedule a one-off action to run in 30 seconds. * as_schedule_single_action() returns the action ID. */ $action_id = as_schedule_single_action( time() + 30, // Unix timestamp: when to run 'my_plugin_send_webhook', // hook name [ 'order_id' => 1234 ] // args passed to the hook ); /** * Schedule a recurring action every 5 minutes. * Use a group name to organise actions by feature or plugin. */ as_schedule_recurring_action( time(), // first run: now 300, // interval in seconds 'my_plugin_process_queue', // hook name [], // no args 'my-plugin' // group (for UI filtering) ); /** * Handle the scheduled hook. Action Scheduler passes the * scheduled args as individual parameters. */ add_action( 'my_plugin_send_webhook', function( $order_id ) { $result = wp_remote_post( 'https://endpoint.example.com/webhook', [ 'body' => wp_json_encode( [ 'order_id' => $order_id ] ), 'headers' => [ 'Content-Type' => 'application/json' ], 'timeout' => 15, ] ); if ( is_wp_error( $result ) || wp_remote_retrieve_response_code( $result ) >= 400 ) { // Throwing an exception marks the action as failed in Action Scheduler's log. throw new Exception( 'Webhook delivery failed for order ' . $order_id ); } } ); ``` **Queue issues to know about.** Action Scheduler's queue runner processes actions in batches. The default batch size is 25 actions per run, and the queue runner itself is triggered by WP-Cron (or your system cron). If you have a large queue — thousands of pending actions — the batch size and runner frequency become important tuning parameters. You can adjust them via filters: action-scheduler-tuning.php — adjust batch size and concurrency ``` // Increase batch size (default: 25) add_filter( 'action_scheduler_queue_runner_batch_size', function() { return 50; } ); // Increase concurrent runners (default: 1) // More runners = more parallel batch processing, but higher DB load add_filter( 'action_scheduler_queue_runner_concurrent_batches', function() { return 3; } ); // Extend time limit for each runner (default: 30 seconds) add_filter( 'action_scheduler_queue_runner_time_limit', function() { return 60; } ); ``` **Stuck jobs.** An action gets stuck in `in-progress` status when the PHP process running it was killed before it could mark the action complete or failed. Action Scheduler has a claim timeout (default 5 minutes) after which a stuck action is released back to the queue and retried. If you see many `in-progress` actions persisting, either your PHP time limit is lower than the claim timeout, or the actions themselves are hanging on a blocking call. **When to use Action Scheduler vs WP-Cron.** Use WP-Cron (with a real system cron behind it) for simple, low-frequency, idempotent tasks where losing a single run occasionally is acceptable. Reach for Action Scheduler when the task involves a growing queue of individual jobs, requires per-job failure tracking, or must not lose work if a PHP process dies mid-execution. ![A colossal derelict robot slumped in a dark hall, one optic flaring back on and throwing a shaft of light across the floor, a tether running from its skull to a tiny human silhouette in a distant doorway, cyberpunk illustration](https://wpwebhooks.org/blog/cron-job-for-wordpress/og_image.jpg) try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Debugging ## Diagnosing cron problems: **tools and techniques** Most WP-Cron problems are invisible by default. Events that should have run 3 hours ago show no error — they simply have not run. The first step in diagnosing any cron issue is making the schedule visible. **WP-CLI is the fastest way to diagnose a cron job for WordPress that is not running as expected.** These commands are safe to run on a live site and do not modify state: WP-CLI — cron diagnostics reference ``` # Show all scheduled events: hook name, next run, interval, args wp cron event list # Show events sorted by next run time (overdue events appear first) wp cron event list --fields=hook,next_run_relative,recurrence --orderby=next_run_timestamp # Manually run a specific hook (useful for testing without waiting) wp cron event run my_plugin_process_queue # Run all overdue events immediately wp cron event run --due-now # Show registered schedules (intervals and their labels) wp cron schedule list # Delete a specific scheduled event (by hook name) wp cron event delete my_plugin_process_queue ``` If `wp cron event list` shows events with a `next_run_relative` of "3 hours ago" or more, the cron system is not executing reliably. Check whether `DISABLE_WP_CRON` is set and whether your system cron job is actually running. **Checking the raw cron option.** The `_cron` option holds the canonical scheduler state. You can inspect it directly from MySQL or via WP-CLI: Check \_cron option — raw scheduler state ``` # Via WP-CLI (truncated output; use --format=json for full detail) wp option get cron # Via MySQL — unserialise to readable form wp db query "SELECT option_value FROM wp_options WHERE option_name = 'cron'" | php -r "echo serialize(unserialize(file_get_contents('php://stdin')));" ``` The structure is a PHP serialised array: the outer keys are Unix timestamps (next run times), the inner keys are hook names. An event with a timestamp far in the past means it was scheduled but never executed and never rescheduled — the cron runner is not running. **WordPress Site Health.** Navigate to **Tools → Site Health → Info → Scheduled Events**. This section reports whether WP-Cron has run within the last hour and lists any late events. It is less detailed than WP-CLI but accessible without server access. **Action Scheduler admin UI.** If you are using Action Scheduler, the full job log is available at `/wp-admin/admin.php?page=action-scheduler`. You can filter by status (`pending`, `in-progress`, `complete`, `failed`), search by hook name or group, and manually trigger or cancel individual actions. **Logging missed cron runs.** WP-Cron has no built-in logging. If you need to know when the cron runner actually executes — and when it does not — add a lightweight logging hook: mu-plugins/cron-heartbeat.php — log cron execution times ``` /** * Log every WP-Cron execution with a timestamp. * Stored as a WordPress option; rotate it periodically. * Drop this in mu-plugins/ so it loads regardless of plugin state. */ add_action( 'init', function() { if ( defined( 'DOING_CRON' ) && DOING_CRON ) { $log = get_option( 'my_cron_heartbeat_log', [] ); $log[] = current_time( 'mysql', true ); // Keep only the last 100 entries. $log = array_slice( $log, -100 ); update_option( 'my_cron_heartbeat_log', $log, false ); } } ); ``` If you cannot see your scheduled jobs, you cannot debug them. Invest in visibility early: WP-CLI, Site Health, Action Scheduler's admin UI, and a heartbeat log will surface most cron problems within minutes. Discovering that cron has not run in 6 hours during an incident is a different experience from seeing it in a dashboard. / Error strings ## "There was a problem **spawning a call to the WP-Cron system**" This message does not come from WordPress. It is emitted by the [WP Crontrol](https://wordpress.org/plugins/wp-crontrol/) plugin, from its `test_cron_spawn()` function, and the full text is _"There was a problem spawning a call to the WP-Cron system on your site. This means WP-Cron events on your site may not work. The problem was: %s"_. Knowing the emitter matters, because the test it performs is narrower than people assume. WP Crontrol posts to `wp-cron.php` with a `doing_wp_cron` query argument and a **3-second timeout**, then treats **any response code of 300 or above** as a failure. So the trailing `%s` tells you which of several very different problems you have: | The problem was… | Actual cause | Fix | | --- | --- | --- | | Unexpected HTTP response code: 301 | A redirect sits in front of wp-cron.php — usually http→https or www→bare | Make site\_url() match the canonical scheme and host | | Unexpected HTTP response code: 403 | A firewall, security plugin or host rule is blocking wp-cron.php | Allow requests to wp-cron.php from the server itself | | Unexpected HTTP response code: 401 | HTTP Basic auth on a staging site | Allowlist wp-cron.php, or accept the warning on staging | | cURL error 28: Operation timed out | The site takes longer than 3 seconds to answer its own request | A slow site, not a broken cron — check TTFB first | | cURL error 6 / 7 | DNS or connectivity failure resolving your own domain | Usually a container or /etc/hosts issue, not WordPress | Two of those are not cron faults at all. A 3-second timeout is aggressive for a site under load, and a self-signed certificate on staging is normal. Confirm the real behaviour before changing anything: Reproduce the spawn test by hand ``` # Run this ON the server, so it tests the same loopback path curl -sS -o /dev/null -w "%{http_code} %{time_total}s\n" \ -X POST "https://example.com/wp-cron.php?doing_wp_cron=$(date +%s)" # 200 and under 3s -> the warning is stale, clear the transient wp transient delete crontrol-cron-test-ok ``` **The two informational cases.** If `DISABLE_WP_CRON` or `ALTERNATE_WP_CRON` is defined, WP Crontrol never runs the HTTP test at all — it returns early with a `crontrol_info` code and renders a blue notice, not a red one. _"The DISABLE\_WP\_CRON constant is set to true. WP-Cron spawning is disabled."_ is therefore a statement of fact, not an error, and on a site with a real system cron entry it is the message you **want** to see. The same early return happens when a cron-runner plugin such as Cavalcade or Cron Control is active. / Error strings ## "Your site **could not complete a loopback request**" This one _is_ WordPress core, from the Site Health test in `WP_Site_Health::get_test_loopback_requests()`. It is a different test from WP Crontrol's, and the two regularly disagree — which confuses people into thinking one of them is lying. The differences are worth knowing: | | Site Health loopback test | WP Crontrol spawn test | | --- | --- | --- | | Timeout | 10 seconds | 3 seconds | | Sends cookies | Yes — forwards the current $\_COOKIE | No | | Sends HTTP Basic auth | Yes, if PHP\_AUTH\_USER is set | No | | Query argument | None; posts a site-health body instead | doing\_wp\_cron, set to a timestamp | | Result cached | No | Yes, in a transient | A site behind HTTP Basic auth will therefore pass Site Health and fail WP Crontrol, because only one of the two forwards the credentials. A site that answers in 5 seconds passes Site Health and fails WP Crontrol on the timeout alone. Both use a `POST` to `wp-cron.php` deliberately: core's comment explains that posting makes the file finish early without triggering any cron jobs, so the test does not run your queue a second time. If loopbacks genuinely fail, the fix is almost always at the network layer — a firewall rule, a load balancer that will not route traffic back to origin, or a DNS record pointing your own domain somewhere the server cannot reach. / Error strings ## "The scheduled event, X, **failed to run**" Site Health's **Scheduled events** test has three failure states, and the exact wording tells you which: - _"A scheduled event has failed"_ — "The scheduled event, %s, failed to run." An event is **more than 5 minutes** past due. - _"A scheduled event is late"_ — an event is past due but inside that window. - _"It was not possible to check your scheduled events"_ — the `_cron` option itself could not be read, which is a database or serialisation problem, not a scheduling one. **The threshold moves.** This is the detail that makes the warning confusing on a tuned site: when `DISABLE_WP_CRON` is defined and true, Site Health relaxes its own limits from 0 and 5 minutes to **15 minutes and 1 hour**. Disabling WP-Cron makes the warning less likely to appear, not more — so seeing it on a site with a system cron job means the system cron job is genuinely not firing, and the message deserves more attention than it would on a default install. **Beware the plugin lookalikes.** Several plugins emit their own similarly worded notice, and they are not this test. WP Rocket's _"The following scheduled event failed to run… which can prevent some WP Rocket features from working as intended: scheduled cache purge"_ names its own hook and is telling you that one specific event is stuck — not that cron is down. Fix the underlying runner and every one of these clears at once. / Error strings ## "WooCommerce **daily cron not scheduled**" You are almost certainly looking in the wrong list. WooCommerce's recurring jobs are **Action Scheduler actions, not WP-Cron events**, so they never appear in Tools → Cron Events or in WP Crontrol. They live under **WooCommerce → Status → Scheduled Actions**, in the `woocommerce` group. `WooCommerce::register_recurring_actions()` is what schedules them, and reading it answers most of the questions people ask about this warning. It registers, among others: - `woocommerce_scheduled_sales` — daily, first run at midnight tomorrow in site time - `woocommerce_cleanup_logs` and `woocommerce_cleanup_rate_limits_wrapper` — daily at 03:00 - `woocommerce_cleanup_sessions` — every 12 hours, first run at 06:00 - `woocommerce_geoip_updater` — every 15 days - `wc_admin_daily_wrapper` — daily at 03:00 **The first line of that method is a guard, and it is the real cause of a genuinely empty list.** If `as_schedule_recurring_action()` and `as_schedule_single_action()` are not defined, the method `return`s immediately — every daily job is skipped, silently, with no notice anywhere in wp-admin. Action Scheduler ships inside WooCommerce, so this means something prevented it loading: a fatal error earlier in the request, an aggressive optimisation plugin, or another plugin bundling a conflicting copy. **A first run "tomorrow" is not a missing job.** Because `woocommerce_scheduled_sales` is scheduled for midnight _tomorrow_ rather than immediately, a store checked within hours of installing or updating WooCommerce shows a pending action that has never run. That is the design, not a fault. WooCommerce's own source is unusually candid about the remaining case. The comment above the personal-data cleanup notes that _"WooCommerce Admin also schedules a daily cron that gets lost due to a race condition"_, because a background-processing instance updates the schedule from inside a cron job — which is why `woocommerce_cleanup_personal_data` is deliberately delayed by 10 seconds rather than run in the same request. So the order to check is: confirm Action Scheduler is loading at all, then look in Scheduled Actions rather than Cron Events, then confirm the queue runner itself is firing — because an action that is scheduled but never claimed is a runner problem, and everything earlier in this article applies to it. / Webhook Delivery ## How cron reliability feeds **webhook delivery** In any production webhook system, cron is the pump. A background worker — triggered on a schedule — reads jobs from a queue table, dispatches them as HTTP requests to remote endpoints, and records the result. If the pump stops, the queue fills. If the pump runs intermittently, deliveries are delayed and retries pile up out of sequence. The failure chain looks like this: WP-Cron misfires on a low-traffic night → the queue worker does not run → 200 pending webhook jobs accumulate → when cron resumes, all 200 run simultaneously → the destination endpoint receives an unexpected spike → it starts returning 429 or 503 → the worker marks the jobs as failed → retry scheduling kicks in → the downstream system receives events hours late or not at all. This is not a hypothetical. It is the standard failure mode for WordPress webhook systems that use the default WP-Cron scheduler. The solution is the same as for any cron reliability problem: set up a proper cron job for WordPress at the system level — one that fires every minute, regardless of traffic. With a reliable pump, the queue drains predictably and retry logic has a chance to work as designed. There is a second, less obvious cron-related failure: the delivery window. If your webhook queue is designed to deliver events within 5 minutes of the triggering action, but WP-Cron only fires every 15–20 minutes due to traffic patterns, that SLA is broken by design — not by a bug in the queue or the endpoint. Cron interval is a first-class parameter in any webhook delivery architecture. For a deeper look at how WordPress webhook dispatch fails — including timeout handling, silent errors, and what good delivery logging looks like — see [Why WordPress Webhooks Silently Fail in Production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) and [Why WP-Cron Is Not Enough for Async Webhooks](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/). / References ## Official documentation All implementation patterns in this article use WordPress-native APIs and documented constants. Primary references: - [WordPress Developer Handbook — Cron: scheduling, DISABLE\_WP\_CRON, custom schedules](https://developer.wordpress.org/plugins/cron/) - [wp\_schedule\_event() — function reference, parameters, return values](https://developer.wordpress.org/reference/functions/wp_schedule_event/) - [wp\_next\_scheduled() — check if a hook is already scheduled](https://developer.wordpress.org/reference/functions/wp_next_scheduled/) - [cron\_schedules filter — registering custom schedule intervals](https://developer.wordpress.org/reference/hooks/cron_schedules/) - [Action Scheduler — documentation, API reference, GitHub repository](https://actionscheduler.org/) - [WP-CLI cron command — event list, run, delete, schedule management](https://developer.wordpress.org/cli/commands/cron/) / Production Alternative ## If you'd rather not **maintain this yourself** **Flow Systems Webhook Actions** is an open-source async webhook plugin for WordPress that ships with reliable queue processing, exponential backoff retry, and structured delivery logging built in. It uses Action Scheduler as its job runner, so the delivery pipeline is backed by a persistent DB queue rather than the transient-based WP-Cron lock. Delivery logs, retry triggers, and queue status are accessible programmatically via REST API — see the [REST API monitoring article](https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/) and the [full endpoint reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/). The plugin is free, open-source, and available on [GitHub](https://github.com/flowsystems-pl/wordpress-webhook-actions) and [WordPress.org](https://wordpress.org/plugins/flowsystems-webhook-actions/). Full details on the [async webhook plugin page](https://wpwebhooks.org/wordpress-webhook-plugin/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Cron Job: Why WP-Cron Fails (And How to Fix)","description":"WordPress cron job not running? WP-Cron only fires on page load. Replace it with a real system cron in two lines — full setup with WP-CLI and Action Scheduler.","datePublished":"2026-03-30","dateModified":"2026-09-03","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/cron-job-for-wordpress/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/cron-job-for-wordpress/og_image.jpg","width":1200,"height":630,"caption":"A colossal derelict robot slumped in a dark hall, one optic flaring back on and throwing a shaft of light across the floor, a tether running from its skull to a tiny human silhouette in a distant doorway, cyberpunk illustration"},"keywords":["cron job for wordpress","wp cron","wordpress scheduled tasks","wordpress cron job setup","action scheduler","disable wp cron","wordpress background processing"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Cron Job: Why WP-Cron Fails (And How to Fix)","item":"https://wpwebhooks.org/blog/cron-job-for-wordpress/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does WP-Cron run on a schedule like a real cron job?","acceptedAnswer":{"@type":"Answer","text":"No. WP-Cron is not a real scheduler. It is a pseudo-cron system that piggybacks on incoming HTTP requests. When someone visits your WordPress site, the bootstrap process checks whether any scheduled events are overdue and runs them if so. If no one visits your site, no scheduled events run — regardless of their schedule. On low-traffic sites, this means events may be delayed by minutes, hours, or indefinitely."}},{"@type":"Question","name":"How do I check if WP-Cron is running on my WordPress site?","acceptedAnswer":{"@type":"Answer","text":"Use WP-CLI: run `wp cron event list` to see all scheduled events, their next run time, and their recurrence interval. You can also inspect the raw data with `wp option get cron` to see the serialised `_cron` option. If events show next run times far in the past, WP-Cron is not executing reliably. The Site Health tool in WordPress admin (Tools → Site Health → Info → Scheduled Events) will also flag missed or failing events."}},{"@type":"Question","name":"Should I use Action Scheduler or WP-Cron for background tasks?","acceptedAnswer":{"@type":"Answer","text":"Use WP-Cron (backed by a real system cron) for lightweight, low-frequency tasks: sending a scheduled digest email, pruning old logs, running a weekly report. Use Action Scheduler when you need: persistent job storage that survives PHP crashes, parallel processing of large job batches, built-in failure tracking and retry, or a UI to inspect and manage queued jobs. Action Scheduler is the right default for any task that processes variable-length queues or must not lose jobs on failure."}},{"@type":"Question","name":"What happens to scheduled jobs when DISABLE_WP_CRON is set but no system cron is configured?","acceptedAnswer":{"@type":"Answer","text":"Nothing runs. Setting DISABLE_WP_CRON to true tells WordPress to stop piggybacking cron on page loads, but it does not create a system cron replacement. If you set this constant without also configuring a system cron entry to call wp-cron.php, all scheduled events — including core WordPress maintenance tasks, plugin jobs, and your own custom schedules — will silently stop executing. This is a common misconfiguration on managed hosting environments that pre-set the constant."}},{"@type":"Question","name":"How does an unreliable cron affect webhook delivery in WordPress?","acceptedAnswer":{"@type":"Answer","text":"In any async webhook architecture, a background worker — driven by a cron schedule — is responsible for reading queued jobs and dispatching HTTP requests to remote endpoints. If that cron job misfires or runs late, jobs accumulate in the queue unprocessed. The downstream system does not receive the event within the expected window, retries are delayed proportionally, and in severe cases the queue depth grows until memory or time limits become a problem. Reliable cron execution is not optional in a production webhook pipeline — it is the pump that keeps delivery moving."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/cron-job-for-wordpress.png","caption":"On a page load WordPress boots and spawn_cron reads the cron option. If no events are due it keeps serving the request; if events are due it fires a non-blocking request to wp-cron.php, which takes a lock, runs all due hooks, reschedules recurring events, and exits while the original request continues unblocked.","description":"On a page load WordPress boots and spawn_cron reads the cron option. If no events are due it keeps serving the request; if events are due it fires a non-blocking request to wp-cron.php, which takes a lock, runs all due hooks, reschedules recurring events, and exits while the original request continues unblocked.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Create and Manage WordPress Webhooks via REST API" description: "Learn how to create and manage WordPress webhooks via REST API. Control endpoints, enable/disable integrations, and automate setups without code deployments." url: "https://wpwebhooks.org/blog/create-manage-wordpress-webhooks-rest-api/" date: "2026-03-26" --- # Create and Manage WordPress Webhooks via REST API TL;DR - The Webhook Actions REST API covers full webhook lifecycle management: create, list, update, enable/disable, and delete — no wp-admin needed - Create a webhook with `POST /wp-json/fswa/v1/webhooks`; required fields are `name` and `endpoint_url` - Designed for automation — CI pipelines, AI agents, and external systems can provision and manage webhooks programmatically / The Problem ## Why does WordPress need a Webhook **Control Layer**? Most articles about WordPress webhooks focus on _sending_ them — how to fire an HTTP request when an order is placed, a form is submitted, or a user registers. The sending part is well-covered. The missing piece is the control layer: there is no standard way to create webhooks remotely, update their endpoints, toggle integrations on and off, or manage them across multiple environments without logging into each site individually. Without API control, webhooks are static. You write them once in PHP and they stay there until someone edits the code. When an endpoint changes, you deploy. When a staging environment should not fire to production, you comment out code. When a client's integration needs to be paused, you log in and click through the admin UI. This is the difference between hardcoded integrations and API-driven infrastructure. The [delivery layer](https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/) — retry, replay, monitoring — is one part of the picture. The control layer is the other: how you create and manage what gets sent, and where. WordPress's REST API framework — introduced in WordPress 4.7 and documented in the [WordPress REST API Handbook](https://developer.wordpress.org/rest-api/) — provides the foundation for this kind of programmatic control. The same API that powers the Gutenberg block editor and the WordPress mobile apps makes remote webhook management technically possible across hosting providers, CI pipelines, and automation platforms. That shift also quietly retired a generation of PHP admin hooks — [post\_submitbox\_misc\_actions](https://wpwebhooks.org/blog/post-submitbox-misc-actions/) is the canonical example, since the block editor never renders the meta box it fires in. The same framework is what you build on to _receive_ webhooks as well as manage them. When the sender is a payment provider, see [verifying Stripe webhook signatures in WordPress](https://wpwebhooks.org/blog/stripe-webhook-wordpress/) — the raw-body and redirect traps there apply to any signed inbound webhook. / Create ## **Create** a Webhook via REST API Base URL: `https://your-site.com/wp-json/fswa/v1` Auth header: `X-FSWA-Token: ` — requires `full` scope A webhook needs two things: a name and an endpoint URL. Everything else is optional — triggers (which WordPress actions fire this webhook), auth header (sent with each delivery), and whether it starts enabled. curl — create a webhook ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/webhooks" -H "X-FSWA-Token: your-full-scope-token" -H "Content-Type: application/json" -d '{ "name": "CF7 → n8n", "endpoint_url": "https://n8n.yourdomain.com/webhook/abc123", "triggers": ["wpcf7_mail_sent"], "is_enabled": true }' ``` response — JSON ``` { "id": 7, "name": "CF7 → n8n", "endpoint_url": "https://n8n.yourdomain.com/webhook/abc123", "auth_header": null, "auth_credential_id": null, "is_enabled": true, "triggers": ["wpcf7_mail_sent"], "created_at": "2026-03-25 11:04:38", "updated_at": "2026-03-25 11:04:38" } ``` `id` is the webhook identifier used in all subsequent operations. `triggers` maps to WordPress action hook names — use `GET /wp-json/fswa/v1/triggers` to browse all available hooks grouped by category (WooCommerce, Contact Form 7, WordPress core, etc.). ### With authorization — the Credentials Vault If the receiving endpoint requires authentication, the preferred way is to store the secret once in the plugin's **Credentials Vault** and reference it by id via `auth_credential_id`. It takes precedence over the legacy plaintext `auth_header` field, and setting it to `null` clears it. Vault secrets are encrypted at rest and write-only over the API — the value is sent as the `Authorization` header (or a custom header) with every delivery, but can never be read back, not even by the `agent`\-scope tokens used by AI assistants. curl — store the secret once in the Credentials Vault ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/credentials" -H "X-FSWA-Token: your-full-scope-token" -H "Content-Type: application/json" -d '{ "name": "HubSpot PAT", "type": "bearer", "secret": "hs_live_secret_abc123" }' # → { "id": 3, "name": "HubSpot PAT", "hint": "…c123" } — the secret is never returned ``` curl — webhook referencing the vault credential ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/webhooks" -H "X-FSWA-Token: your-full-scope-token" -H "Content-Type: application/json" -d '{ "name": "WooCommerce → HubSpot", "endpoint_url": "https://hooks.example.com/crm/orders", "auth_credential_id": 3, "triggers": ["woocommerce_order_status_completed"], "is_enabled": true }' ``` ### JavaScript — fetch JavaScript — create webhook ``` const response = await fetch( 'https://your-site.com/wp-json/fswa/v1/webhooks', { method: 'POST', headers: { 'X-FSWA-Token': 'your-full-scope-token', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'CF7 → n8n', endpoint_url: 'https://n8n.yourdomain.com/webhook/abc123', triggers: ['wpcf7_mail_sent'], is_enabled: true }) } ); const webhook = await response.json(); // webhook.id — use this for all subsequent operations ``` / List & Inspect ## **List** and Inspect Webhooks Listing webhooks requires only a `read` scope token — you don't need write access to audit what's configured. This is the right token scope for monitoring scripts, dashboards, and read-only tooling. curl — list all webhooks ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/webhooks" -H "X-FSWA-Token: your-read-scope-token" # Add ?only_enabled=true to return only active webhooks ``` response — JSON (array) ``` [ { "id": 5, "name": "Order sync — HubSpot", "endpoint_url": "https://hooks.example.com/crm/orders", "is_enabled": true, "triggers": ["woocommerce_order_status_completed"], "created_at": "2026-02-10 08:30:00", "updated_at": "2026-03-24 14:15:22" }, { "id": 7, "name": "CF7 → n8n", "endpoint_url": "https://n8n.yourdomain.com/webhook/abc123", "is_enabled": true, "triggers": ["wpcf7_mail_sent"], "created_at": "2026-03-25 11:04:38", "updated_at": "2026-03-25 11:04:38" } ] ``` ### Get a single webhook curl — get webhook by ID ``` curl -X GET "https://your-site.com/wp-json/fswa/v1/webhooks/7" -H "X-FSWA-Token: your-read-scope-token" ``` / Update & Control ## **Update** and Control Webhooks ### Update a webhook `PATCH /webhooks/{id}` accepts any subset of fields — you only send what you want to change. Requires `full` scope. curl — change endpoint URL ``` curl -X PATCH "https://your-site.com/wp-json/fswa/v1/webhooks/7" -H "X-FSWA-Token: your-full-scope-token" -H "Content-Type: application/json" -d '{ "endpoint_url": "https://n8n.yourdomain.com/webhook/production-xyz" }' ``` curl — add a trigger to an existing webhook ``` curl -X PATCH "https://your-site.com/wp-json/fswa/v1/webhooks/5" -H "X-FSWA-Token: your-full-scope-token" -H "Content-Type: application/json" -d '{ "triggers": ["woocommerce_order_status_completed", "woocommerce_order_status_refunded"] }' ``` ### Toggle a webhook on / off `POST /webhooks/{id}/toggle` flips the `is_enabled` flag. No request body required. This endpoint needs only `operational` scope — not `full`. Toggle requires only `operational` scope. This is intentional: CI pipelines and deployment scripts that need to pause integrations during a migration don't need full write access — they just need to flip a switch. Create a dedicated `operational` token for these workflows. curl — toggle webhook ``` curl -X POST "https://your-site.com/wp-json/fswa/v1/webhooks/7/toggle" -H "X-FSWA-Token: your-operational-token" # No request body needed. Flips current is_enabled state. ``` response — JSON ``` { "id": 7, "name": "CF7 → n8n", "endpoint_url": "https://n8n.yourdomain.com/webhook/abc123", "is_enabled": false, "triggers": ["wpcf7_mail_sent"], "updated_at": "2026-03-25 11:44:09" } // Always check is_enabled in the response — that is your confirmation. ``` ### Delete a webhook Remove a webhook entirely. Requires `full` scope. This cannot be undone — the webhook and its configuration are deleted permanently. curl — delete webhook ``` curl -X DELETE "https://your-site.com/wp-json/fswa/v1/webhooks/7" -H "X-FSWA-Token: your-full-scope-token" ``` response — JSON ``` { "deleted": true, "id": 7 } ``` / Use Cases ## What are the Real-World **Use Cases** for webhook REST APIs? ### CI/CD and environment setup During a deployment pipeline, create environment-specific webhooks automatically. Point staging to a test endpoint, production to the live one. Disable all webhooks before a database migration, re-enable after it completes. No one logs into wp-admin during a deployment window. bash — CI/CD deployment script ``` SITE="https://your-site.com/wp-json/fswa/v1" TOKEN="${FSWA_FULL_TOKEN}" # Disable all webhooks before migration WEBHOOK_IDS=$(curl -s "${SITE}/webhooks?only_enabled=true" -H "X-FSWA-Token: ${TOKEN}" | jq '[.[].id]') for id in $(echo "${WEBHOOK_IDS}" | jq '.[]'); do curl -s -X POST "${SITE}/webhooks/${id}/toggle" -H "X-FSWA-Token: ${TOKEN}" > /dev/null done # ... run migration ... # Re-enable after migration completes for id in $(echo "${WEBHOOK_IDS}" | jq '.[]'); do curl -s -X POST "${SITE}/webhooks/${id}/toggle" -H "X-FSWA-Token: ${TOKEN}" > /dev/null done ``` ### SaaS integrations If you're building a product that integrates with customer WordPress sites, you can register webhooks programmatically during onboarding. No manual configuration. No asking customers to copy-paste an endpoint URL into a form. The webhook is created, the trigger is registered, the integration is live — from a single API call in your onboarding flow. ### Automation platforms — n8n, Make, Zapier When building flows in n8n or similar platforms, endpoint URLs change as you move between test and production workflows. Instead of updating WordPress manually every time, your automation can call the PATCH endpoint to update the webhook URL when a workflow is promoted to production. The endpoint stays in sync with the automation, not the other way around. Both [n8n](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/) and [Zapier](https://zapier.com/apps/webhook/integrations) rely on webhook ingestion as their primary real-time trigger mechanism. When endpoint URLs differ between test and production workflow environments, programmatic webhook management eliminates a category of manual error that typically only surfaces during a live deployment. For a practical example of sending CF7 form data to n8n, see the [Contact Form 7 → webhook example](https://wpwebhooks.org/examples/cf7-to-webhook/). ### Agency / multi-site management Managing webhooks across ten client sites means ten separate wp-admin logins to push a configuration change. With API control, one script handles all of them. Standardize webhook configuration across environments, audit what's enabled on each site, roll out endpoint changes without browser tabs. / Architecture ## Two Layers of a **Complete Webhook System** Webhook infrastructure has two distinct concerns. Most systems handle only one of them. | Layer | Operation | Method & endpoint | | --- | --- | --- | | Control | create | `POST /webhooks` | | Control | list | `GET /webhooks` | | Control | inspect | `GET /webhooks/{id}` | | Control | update | `PATCH /webhooks/{id}` | | Control | enable / disable | `POST /webhooks/{id}/toggle` | | Control | remove | `DELETE /webhooks/{id}` | | Delivery | retry | `POST /logs/{id}/retry` | | Delivery | replay | `POST /logs/{id}/replay` | | Delivery | monitor | `GET /health` | | Delivery | queue depth | `GET /queue/stats` | | Delivery | inspect errors | `GET /logs?status=error` | | Delivery | bulk recover | `POST /logs/bulk-retry` | The control layer (this article) handles what exists: which webhooks are registered, where they point, and whether they're active. The delivery layer handles what happened: did events reach their destination, what failed, how to recover. Together they give you webhooks that are both reliable and controllable. The delivery layer is covered in the companion article: [WordPress Webhooks REST API: Retry, Replay and Monitor Events Programmatically](https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/). The same scoped toolset is also published to AI agents through the [WordPress Abilities API](https://wpwebhooks.org/blog/wordpress-abilities-api-ai-agents/). **Flow Systems Webhook Actions** exposes both layers via the same authenticated REST API. The full reference — all endpoints, request/response schemas, and scope requirements — is in the [API documentation](https://wpwebhooks.org/webhook-wordpress-plugin-api/). / Authentication ## **Authentication** and Token Scopes Tokens are issued per-site from the plugin admin panel under _API Tokens_. Each token carries one scope. Use the minimum scope required for the operation — don't give a monitoring script write access. | Scope | Permitted Operations | Typical Use | | --- | --- | --- | | read | GET /webhooks, GET /webhooks/{id}, GET /logs, GET /health, GET /queue/stats | Monitoring, dashboards, auditing | | operational | All read operations, plus: POST /webhooks/{id}/toggle, POST /logs/{id}/retry, POST /logs/bulk-retry, POST /logs/{id}/replay | CI/CD pipelines, recovery scripts | | full | All operational operations, plus: POST /webhooks (create), PATCH /webhooks/{id} (update), DELETE /webhooks/{id} | Onboarding scripts, provisioning | The token is passed in the `X-FSWA-Token` header. Alternatively, use `Authorization: Bearer ` if your HTTP client expects a Bearer auth format, or append `?api_token=` as a query parameter for tools that don't support custom headers. Tokens are independently revocable. If a token is exposed in a log or a script, revoke it from the plugin settings without disrupting other integrations. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Create and Manage WordPress Webhooks via REST API","description":"Learn how to create and manage WordPress webhooks via REST API. Control endpoints, enable/disable integrations, and automate setups without code deployments.","datePublished":"2026-03-26","dateModified":"2026-03-26","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/create-manage-wordpress-webhooks-rest-api/","image":"https://wpwebhooks.org/blog/create-manage-wordpress-webhooks-rest-api/og_image.jpg","keywords":["create wordpress webhook","wordpress webhook api","manage wordpress webhooks","wordpress rest api webhook","wordpress automation","webhook control plane"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Create and Manage WordPress Webhooks via REST API","item":"https://wpwebhooks.org/blog/create-manage-wordpress-webhooks-rest-api/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Can you create webhooks in WordPress via API?","acceptedAnswer":{"@type":"Answer","text":"Yes. The Flow Systems Webhook Actions plugin exposes POST /wp-json/fswa/v1/webhooks, which creates a new webhook from a JSON payload. Required fields are name and endpoint_url. Optional fields include triggers (array of WordPress action names), is_enabled (boolean), and auth_credential_id — the ID of an encrypted Credentials Vault entry used for authorization, preferred over (and taking precedence over) the legacy plaintext auth_header. Requires a token with full scope."}},{"@type":"Question","name":"How to manage WordPress webhooks programmatically?","acceptedAnswer":{"@type":"Answer","text":"Use PATCH /wp-json/fswa/v1/webhooks/{id} to update any field (endpoint URL, name, triggers) — all fields are optional so you can patch only what changed. Use POST /webhooks/{id}/toggle to flip the is_enabled flag without a full update (requires only operational scope). Use DELETE /webhooks/{id} to remove a webhook entirely. All operations are authenticated via the X-FSWA-Token header."}},{"@type":"Question","name":"What token scope is needed to create WordPress webhooks via API?","acceptedAnswer":{"@type":"Answer","text":"Creating, updating, and deleting webhooks requires a token with full scope. Toggling a webhook on or off (POST /webhooks/{id}/toggle) requires only operational scope — useful for CI pipelines that need to pause integrations without full write access. Listing and inspecting webhooks (GET /webhooks) requires only read scope. For AI assistants, the agent scope grants the same write access as full but can never reveal a webhook's Authorization header or any stored Credentials Vault secret."}},{"@type":"Question","name":"How do I list all webhooks via REST API?","acceptedAnswer":{"@type":"Answer","text":"Use GET /wp-json/fswa/v1/webhooks with an X-FSWA-Token header carrying a read scope token. The response is a JSON array where each item includes id, name, endpoint_url, is_enabled, triggers, created_at, and updated_at. Add ?only_enabled=true to filter to active webhooks only."}}]} ``` --- --- title: "WordPress Webhooks REST API: Retry, Replay & Delivery Logs" description: "Retry failed webhooks, replay any event, and monitor queue health from a REST API — no wp-admin. Full endpoint reference for the WP Webhooks plugin." url: "https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/" date: "2026-03-19" --- # WordPress Webhooks REST API: Retry, Replay & Delivery Logs TL;DR - The Webhook Actions REST API lets you inspect delivery logs, retry failed events, replay any stored event, and monitor queue health without touching wp-admin - Bulk retry and bulk replay endpoints let you recover from outages or resend backlogs programmatically — one API call, multiple events - All endpoints live under `/wp-json/fswa/v1/` and authenticate via the `X-FSWA-Token` header / The Problem ## Why treat webhooks as **One-Shot HTTP**? The default WordPress webhook is a single HTTP call made inline during the PHP request. It fires once. If the receiving endpoint is down, rate-limited, or returns a 500, the delivery attempt produces nothing — no record, no alert, no rescheduled retry. The event is not just undelivered. It is gone. There is no delivery log to inspect, no failure to surface, no mechanism to recover. Integration drift accumulates silently: your CRM, ERP, or automation platform diverges from WordPress reality while both systems report nominal health. Even with [retry and replay infrastructure in place](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/), operating that infrastructure required logging into wp-admin. Useful for developers in the browser — not useful for monitoring systems, CI pipelines, or automation agents that need to query or act on delivery state without human intervention. AI agents now get the same programmatic surface too, via the [WordPress Abilities API and MCP](https://wpwebhooks.org/blog/wordpress-abilities-api-ai-agents/). The failure modes that make this the default are covered in [why WordPress webhooks silently fail in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) — the structural problems that make the default model unreliable at scale. / The Idea ## How do you move from triggers to **Event Lifecycle Management**? The shift worth making is conceptual: stop thinking about webhooks as triggers and start treating them as an event pipeline with a lifecycle — persistence, dispatch, observation, recovery. That lifecycle already existed inside the plugin. Every event is stored before dispatch. Failures enter a retry queue with exponential backoff. Successful deliveries remain available for manual replay. The full attempt history — timestamps, HTTP status codes, response bodies — is recorded per event. The API was always there, powering the wp-admin UI. What changed is that it is now exposed. The same operations available in the WordPress admin panel — inspect logs, retry failures, replay events, check queue health, toggle webhooks — are now reachable via authenticated HTTP from any system that can make a web request. This turns WordPress webhook infrastructure into something operable: scriptable, monitorable, automatable, and auditable without a browser. / What You Can Do ## API **Capabilities** ### Inspect Delivery Logs Query the full history of every webhook delivery attempt. Filter by status (`pending`, `success`, `error`, `retry`, `permanently_failed`), by specific webhook ID, or by trigger name. Each log record includes the HTTP response code, response body, timestamp, attempt number, and the original payload that was sent. Delivery stats are available separately — aggregate counts and success rates over a configurable time window — for dashboards and health checks. ### Retry Failed Events Trigger a retry for any failed delivery log via the API. The retry uses the stored payload verbatim — the original WordPress action is not re-fired. This means you can recover a failed WooCommerce order event without creating a duplicate order, re-send a failed form submission without re-running the form hook, or recover any dead-letter event without side effects in WordPress itself. Bulk retry accepts an array of log IDs, useful for recovering a batch of failures that occurred during a downstream outage. ### Replay Events Replay resends a previously successful delivery. Where retry is for failures, replay is for intentional resend — debugging a receiving system bug, rebuilding state after a migration, or testing a fix to an endpoint that was previously processing payloads incorrectly. The `X-Event-ID` header carries the same UUID as the original event, giving receiving endpoints the information they need to deduplicate if needed. ### Monitor Queue Health `GET /queue/stats` returns pending, processing, completed, and failed counts. `GET /health` returns an aggregate overview — queue health, velocity metrics, recent failure rates. Both endpoints are lightweight enough to poll from a monitoring system or include in a dashboard. ### Enable / Disable Webhooks Programmatically Toggle any webhook on or off via the API. Useful for deployment workflows (disable webhooks during a database migration, re-enable after), for incident response (disable a misbehaving webhook without touching wp-admin), or for test environments that should not fire to production endpoints. If you need the full webhook provisioning API — creating new webhooks, updating endpoint URLs and triggers, listing all registered webhooks, or deleting them — that is covered in the companion article: [Create and Manage WordPress Webhooks Programmatically](https://wpwebhooks.org/blog/create-manage-wordpress-webhooks-rest-api/). / Code Examples ## **API Examples** Base URL: `https://your-site.com/wp-json/fswa/v1` Auth header: `X-FSWA-Token: ` ### 1\. List Failed Delivery Logs Filter by status, webhook ID, or trigger name. Requires `read` scope. curl ``` curl -X GET \ "https://your-site.com/wp-json/fswa/v1/logs?status=error" \ -H "X-FSWA-Token: your-token" ``` JavaScript — fetch ``` const response = await fetch( 'https://your-site.com/wp-json/fswa/v1/logs?status=error', { headers: { 'X-FSWA-Token': 'your-token' } } ); const logs = await response.json(); // Available status values: pending | success | error | retry | permanently_failed // Optional filters: ?webhook_id=5&trigger_name=woocommerce_order_created ``` response — JSON ``` [ { "id": 42, "trigger_name": "woocommerce_order_created", "status": "error", "http_code": "503", "response_body": "Service Unavailable", "error_message": "HTTP 503: Service Unavailable", "duration_ms": "2341", "created_at": "2026-03-19 09:14:22", "event_uuid": "d4e1f8a2-3c9b-4f1e-a7d2-8b5c1e6f9a3d", "attempt_history": [ { "attempt": 1, "http_code": 503, "status": "error", "attempted_at": "2026-03-19T09:14:22Z" }, { "attempt": 2, "http_code": 503, "status": "error", "attempted_at": "2026-03-19T09:44:22Z" } ] } ] ``` `response_body` surfaces the exact message returned by the downstream endpoint — the fastest way to identify whether the failure was a 503 outage, a 401 auth issue, or a 422 schema mismatch. `attempt_history` shows the full retry trail per event, including timestamps and per-attempt HTTP codes. ### 2\. Get Delivery Stats Aggregate success/failure counts over a time window. Requires `read` scope. curl ``` curl -X GET \ "https://your-site.com/wp-json/fswa/v1/logs/stats?days=7" \ -H "X-FSWA-Token: your-token" ``` JavaScript — fetch ``` const stats = await fetch( 'https://your-site.com/wp-json/fswa/v1/logs/stats?days=7', { headers: { 'X-FSWA-Token': 'your-token' } } ).then(r => r.json()); // Optional: ?webhook_id=5 to scope to one webhook ``` response — JSON ``` { "success": 38, "error": 0, "retry": 2, "permanently_failed": 3, "pending": 0, "avg_duration_ms": 234, "http_2xx": 38, "http_4xx": 1, "http_5xx": 4, "total": 43 } ``` `permanently_failed` is the number to alert on. `avg_duration_ms` tracks endpoint latency over time — a gradual increase signals a degrading downstream service before it starts hard-failing. ### 3\. Retry a Failed Event Re-sends the stored payload without re-triggering the WordPress action. Requires `operational` scope. curl ``` curl -X POST \ "https://your-site.com/wp-json/fswa/v1/logs/42/retry" \ -H "X-FSWA-Token: your-token" ``` JavaScript — fetch ``` const logId = 42; const result = await fetch( `https://your-site.com/wp-json/fswa/v1/logs/${logId}/retry`, { method: 'POST', headers: { 'X-FSWA-Token': 'your-token' } } ).then(r => r.json()); ``` response — JSON ``` { "success": true, "job_id": 74 } ``` `job_id` is the queue entry for this retry. Use it to correlate with `GET /queue/stats` output when debugging whether the job was picked up by the dispatcher. ### 4\. Bulk Retry Multiple Failed Events Recover a batch of failures at once. Requires `operational` scope. curl ``` curl -X POST \ "https://your-site.com/wp-json/fswa/v1/logs/bulk-retry" \ -H "X-FSWA-Token: your-token" \ -H "Content-Type: application/json" \ -d '{"ids": [42, 43, 44]}' ``` JavaScript — fetch ``` await fetch( 'https://your-site.com/wp-json/fswa/v1/logs/bulk-retry', { method: 'POST', headers: { 'X-FSWA-Token': 'your-token', 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [42, 43, 44] }) } ); ``` response — JSON ``` { "retried": 3, "skipped": 0 } ``` `skipped` counts IDs that were not in a retriable state — already pending, processing, or succeeded. Non-zero means your ID set included events that didn't need recovery. Check this in automated scripts to detect stale IDs. ### 5\. Replay a Successful Event Resend a previously delivered event. Carries the original `X-Event-ID` for deduplication. Requires `operational` scope. curl ``` curl -X POST \ "https://your-site.com/wp-json/fswa/v1/logs/17/replay" \ -H "X-FSWA-Token: your-token" ``` JavaScript — fetch ``` const logId = 17; await fetch( `https://your-site.com/wp-json/fswa/v1/logs/${logId}/replay`, { method: 'POST', headers: { 'X-FSWA-Token': 'your-token' } } ); ``` ### 6\. Queue Stats & Health Poll queue depth and system health from a monitoring system or uptime dashboard. Requires `read` scope. curl — queue stats ``` curl -X GET \ "https://your-site.com/wp-json/fswa/v1/queue/stats" \ -H "X-FSWA-Token: your-token" # Returns: pending, processing, completed, failed counts ``` curl — system health overview ``` curl -X GET \ "https://your-site.com/wp-json/fswa/v1/health" \ -H "X-FSWA-Token: your-token" # Returns: aggregate stats, queue health, velocity metrics ``` response — JSON (/health) ``` { "success_rate": 89, "webhooks": { "total": 4, "active": 4 }, "logs": { "total": 43, "success": 38, "permanently_failed": 3, "retry": 2 }, "queue": { "pending": 2, "processing": 0, "permanently_failed": 3, "due_now": 2 }, "velocity": { "last_hour": 12, "last_day": 43, "avg_duration_ms": 234 }, "observability": { "avg_attempts_per_event": 1.4, "queue_stuck": false, "wp_cron_only": false } } ``` `queue.due_now` is the most actionable field for alerting — non-zero means jobs are overdue and WP-Cron may not be running. `observability.queue_stuck` flags this explicitly. `velocity.last_hour` and `avg_duration_ms` feed directly into dashboard panels without additional aggregation. ### 7\. Toggle a Webhook On / Off Disable during deployments or incidents. Re-enable without touching wp-admin. Requires `operational` scope. curl ``` curl -X POST \ "https://your-site.com/wp-json/fswa/v1/webhooks/3/toggle" \ -H "X-FSWA-Token: your-token" # No request body needed. Toggles current state. ``` JavaScript — fetch ``` const webhookId = 3; await fetch( `https://your-site.com/wp-json/fswa/v1/webhooks/${webhookId}/toggle`, { method: 'POST', headers: { 'X-FSWA-Token': 'your-token' } } ); ``` response — JSON ``` { "id": 3, "name": "Order sync — HubSpot", "endpoint_url": "https://hooks.example.com/wp/order", "is_enabled": false, "updated_at": "2026-03-19 14:22:11" } ``` Returns the full updated webhook state. Always check `is_enabled` in your script rather than assuming the toggle succeeded — this is the confirmation. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Real-World Example ## WooCommerce → HubSpot: **Recovering from an Outage** A WooCommerce store sends order events to HubSpot via webhook. HubSpot has a 9-minute API outage. Forty-three order events fail to deliver during that window. ### Before the API The team gets a Slack alert from their monitoring system: webhook failure rate spiked. Someone logs into wp-admin, navigates to the delivery log, confirms the failures, then starts clicking "retry" individually. For 43 events. Then checks back manually to confirm each one succeeded. The same operations in the Symfony CLI wrapper someone wrote six months ago: bash — before ``` # Manual recovery script — required SSH access + Symfony CLI php bin/console webhooks:list-failed --format=json | \ jq '.[] | .id' | \ xargs -I{} php bin/console webhooks:retry {} # Required: SSH access, PHP CLI, Symfony console, custom commands # Risk: fires during business hours, no rate limiting, no audit trail ``` ### After the API HubSpot's status page shows all-clear. The monitoring system calls a recovery script automatically — no human in the loop: bash — after ``` # Automated recovery — triggered by monitoring system SITE="https://store.example.com/wp-json/fswa/v1" TOKEN="${FSWA_TOKEN}" # Get all permanently failed log IDs IDS=$(curl -s \ "${SITE}/logs?status=permanently_failed&webhook_id=7" \ -H "X-FSWA-Token: ${TOKEN}" \ | jq '[.[].id]') # Bulk retry in one request curl -s -X POST \ "${SITE}/logs/bulk-retry" \ -H "X-FSWA-Token: ${TOKEN}" \ -H "Content-Type: application/json" \ -d "{"ids": ${IDS}}" # No SSH. No wp-admin. No manual clicks. Auditable. Repeatable. ``` The same pattern works for more complex scenarios: disable a webhook before a database migration, replay a specific order event to test a receiving-side bug fix, query queue depth every 60 seconds from a Grafana datasource. All from outside WordPress, with no wp-admin dependency. / Architecture Diagram ## Event **Lifecycle** Every webhook event follows this path. The REST API provides programmatic access to every node in the lifecycle — logs, retry queue, replay, health. | Method & endpoint | Purpose | | --- | --- | | `GET /logs` | Inspect deliveries of any status | | `GET /queue/stats` | Queue depth | | `GET /health` | Aggregate overview | | `POST /logs/{id}/retry` | Re-send a stored payload | | `POST /logs/bulk-retry` | Batch recovery | | `POST /logs/{id}/replay` | Intentional resend | | `POST /webhooks/{id}/toggle` | Enable / disable a webhook | The API surfaces the full delivery history at every node. `GET /logs` queries any status. `GET /queue/stats` exposes queue depth. `GET /health` aggregates across the whole pipeline. Retry and replay endpoints operate directly on stored payloads — no WordPress action re-fires, no side effects. See the [async webhook architecture post](https://wpwebhooks.org/blog/async-webhooks-wordpress/) for how dispatch is decoupled from the PHP request cycle. / Authentication ## Token **Scopes** Tokens are issued per-site from the plugin settings panel. Each token carries one of three scopes. Use the minimum scope required for the operation. | Scope | Header | Permitted operations | | --- | --- | --- | | `read` | `X-FSWA-Token` | `GET /logs`, `GET /logs/stats`, `GET /queue/stats`, `GET /health` | | `operational` | `X-FSWA-Token` | All read operations, plus `POST /logs/{id}/retry`, `POST /logs/bulk-retry`, `POST /logs/{id}/replay`, `POST /webhooks/{id}/toggle` | | `full` | `X-FSWA-Token` | All operational operations, plus the webhook configuration endpoints | Monitoring systems and dashboards should use `read` tokens. Recovery scripts and CI pipelines should use `operational`. No token should ever carry more scope than its use case requires. Tokens are revocable independently. If an `operational` token is exposed in a script or log, revoke it from the plugin settings without affecting other tokens or the site's webhook configuration. / AI-Driven Workflows ## An Unexpected **Side Effect** When the API was first exposed internally, the expected use cases were monitoring scripts and CI pipelines. The less expected use case emerged almost immediately: AI coding agents. Claude Code, Cursor, and similar agents can invoke the API directly when given a token with `read` or `operational` scope. An agent debugging an integration failure can inspect the delivery log, examine the response body, identify the failure pattern, and retry the affected events — without a developer opening a browser or logging into wp-admin. This is a meaningful shift in how webhook infrastructure gets operated. An on-call agent can run a morning health check: `GET /health`, surface any permanently failed events, identify the affected webhooks, check if the downstream service is back up (via a separate status check), and issue a `bulk-retry` if appropriate. The human reviews the action log, not the raw data. The prerequisite for this to work is exactly what the API provides: a machine-readable interface to delivery state, with idempotent operations that have clear, bounded effects. / When This Approach Makes Sense ## Operational **Fit** The REST API is the right approach when: - **your team operates on-call** and needs a recovery path that does not require wp-admin access or SSH to the server during an incident. - **you run CI/CD pipelines** that deploy to environments with webhooks configured — disable before deploy, re-enable after, verify queue is draining normally. - **you have a monitoring system** that should alert on backlog depth, failure rate, or permanently failed events — metrics the API surfaces directly. - **you manage multiple WordPress sites** and want a single control plane for webhook health across all of them, without logging into each site individually. - **your receiving systems occasionally have outages** and you need a reliable, scriptable recovery path — especially for batch recovery of many events at once. - **you are building AI-assisted operations tooling** and want agents to be able to observe and act on webhook delivery state as part of a broader automation. If you are running a simple WordPress site with one or two webhooks and low event volume, the wp-admin UI is probably sufficient. The API adds value when the operational complexity of managing webhook delivery outgrows what a browser-based interface can handle efficiently. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Webhooks REST API: Retry, Replay & Delivery Logs","description":"Retry failed webhooks, replay any event, and monitor queue health from a REST API — no wp-admin. Full endpoint reference for the WP Webhooks plugin.","datePublished":"2026-03-19","dateModified":"2026-03-19","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/","image":"https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/og_image.jpg","keywords":["wordpress webhooks","webhook rest api","webhook retry api","webhook replay api","wordpress automation","webhook monitoring"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Webhooks REST API: Retry, Replay & Delivery Logs","item":"https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Can I retry failed WordPress webhook deliveries via a REST API?","acceptedAnswer":{"@type":"Answer","text":"Yes. The Flow Systems Webhook Actions plugin exposes POST /wp-json/fswa/v1/logs/{id}/retry, which re-sends a failed delivery using the stored payload without re-triggering the original WordPress action. Bulk retry is also available via POST /logs/bulk-retry with an array of log IDs."}},{"@type":"Question","name":"How do I inspect WordPress webhook delivery logs programmatically?","acceptedAnswer":{"@type":"Answer","text":"Use GET /wp-json/fswa/v1/logs?status=error to list failed deliveries, or GET /logs/stats?days=7 for aggregated stats. Filter by webhook_id, trigger_name, or status (pending, success, error, retry, permanently_failed). Requires a token with read scope."}},{"@type":"Question","name":"What authentication does the WordPress webhook REST API use?","acceptedAnswer":{"@type":"Answer","text":"The API uses token-based authentication via the X-FSWA-Token header. Tokens are scoped: read for inspection, operational for retry/replay/toggle, full for all operations including configuration changes, and agent — the same write access as full but it can never reveal a webhook's Authorization header or any stored Credentials Vault secret, intended for AI assistants."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-webhooks-rest-api-1.png","caption":"A do_action fires the HooksHandler listener, which dispatches, builds a payload, and enqueues a pending job. The WP-Cron or Action Scheduler runner delivers it: a 2xx logs success, a 5xx or timeout enters the retry queue with backoff until max attempts mark it permanently failed, and any status can be replayed manually.","description":"A do_action fires the HooksHandler listener, which dispatches, builds a payload, and enqueues a pending job. The WP-Cron or Action Scheduler runner delivers it: a 2xx logs success, a 5xx or timeout enters the retry queue with backoff until max attempts mark it permanently failed, and any status can be replayed manually.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Webhook Retry, Replay & Dead-Letter Queue" description: "Learn how to build reliable WordPress webhooks using payload storage, automatic retries for failures, and replay for successful events that need reprocessing." url: "https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/" date: "2026-03-09" --- # WordPress Webhook Retry, Replay & Dead-Letter Queue TL;DR - WordPress has no built-in webhook persistence — every failed delivery disappears unless you store the payload before the first HTTP attempt - Retry (automatic, for failures) and replay (manual, for any stored event) are distinct mechanisms — you need both for a complete system - The stored record — body, headers, URL, and original timestamp — is the only recovery path for retry, replay, and dead-letter events / The Problem ## The **Webhook Problem** WordPress fires a webhook, the receiving endpoint is down for 30 seconds, and the event is gone forever. No retry. No alert. The order completed, the form submitted, the customer updated — but the downstream system never heard about it. This is the default behavior of every fire-and-forget webhook implementation. The HTTP call is made once, inline, during the PHP request. If it fails, nothing records the failure. If the endpoint returns a 500, nothing reschedules delivery. If PHP crashes mid-request, the attempt disappears entirely. The problem compounds over time. Integration drift accumulates silently — your CRM, ERP, or automation platform slowly diverges from WordPress reality. Users report inconsistencies days later, when there is no forensic record of what happened and no mechanism to recover the lost events. The reliability gap is well-documented in production systems. [Stripe's webhook best practices documentation](https://docs.stripe.com/webhooks) notes that endpoints can be unavailable for a range of reasons — server errors, network issues, timeouts — and recommends building retry logic with exponential backoff as a baseline expectation, not an edge case. Reliable webhook delivery requires two distinct mechanisms: **automatic retry** for failed deliveries, and **manual replay** for events that delivered successfully but need to be resent. Most implementations implement one or neither. This article explains how to build both — and why both are necessary for a production-grade system. See also: [why WordPress webhooks silently fail in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) — a detailed breakdown of every structural failure mode in the default WordPress webhook model. / Reliability ## Why do **Retries Alone** fail? Automatic retry covers the most common failure scenario: a transient network error, a temporary endpoint outage, a brief API rate limit. You send the webhook, it fails, you wait and try again. After a few attempts it succeeds and the problem resolves itself. But retry logic cannot solve every delivery problem. Consider what happens after a successfully delivered event: Your receiving system had a bug. It accepted the webhook and returned 200, but processed the payload incorrectly — wrote wrong data to the database, triggered the wrong automation, or silently dropped the record due to a validation error. By the time you discover the problem, the retry window has long passed. The event has status "delivered." No retry will ever fire. Or your integration was rebuilt. You migrated to a new CRM, redeployed your automation platform, rewrote the receiving endpoint. Now you need to resend six months of order events to populate the new system. Retries only cover the recent past — and only failures at that. Or a downstream service had an outage. Events delivered successfully, but the service did not process them correctly during the outage window. Their support team tells you to resend the events from that period. You have no mechanism to do that. **Replay** is the mechanism that covers these cases. It lets you resend any previously delivered event — on demand, to the original endpoint or a new one — regardless of how long ago it was delivered. Retry handles failures automatically. Replay handles the rest manually. / Mechanisms ## Retry vs **Replay** The two mechanisms are distinct in trigger, process, and use case. Conflating them leads to systems that do one poorly while lacking the other entirely. | Mechanism | Trigger | Use Case | | --- | --- | --- | | Retry | Delivery failure (5xx, timeout, network error) | Temporary network/API issues — system recovers automatically | | Replay | Manual — delivery succeeded | Bug fixes, system rebuilds, integration resync, data recovery | Retry is automatic and reactive — it fires when something goes wrong, without human intervention. Replay is manual and intentional — a developer or operator decides to resend a specific event or batch of events. Both mechanisms require the same prerequisite: **the original payload must be stored**. You cannot retry or replay an event you have not persisted. This is the foundational design decision that everything else depends on. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Architecture ## Architecture of **the System** The system has three components: payload storage, a retry queue, and a replay mechanism. Every webhook event passes through all three in order. When a WordPress event fires — an order completed, a form submitted, a post published — the first action is always to store the payload. Not to send it. Storage happens synchronously, before any HTTP call is made. Once persisted, the event cannot be lost, regardless of what happens next. The delivery attempt is then made asynchronously, outside the WordPress request cycle. Success writes to the event log and marks the event as replay-eligible. Failure enqueues the event for automatic retry with exponential backoff. After exhausting retries, the event reaches dead-letter status and waits for manual intervention. Replay-eligible events remain in the log indefinitely. Any of them can be manually triggered for resend — individually or in bulk — without creating a new event or modifying the original payload. / Storage ## How does **Payload Storage** work? Every webhook event is stored before any delivery is attempted. The stored record includes everything needed to reconstruct the original delivery and any subsequent retry or replay: - **payload\_body** — The complete JSON payload as it will be sent. Stored verbatim — no transformation, no reference to the original WordPress object that may have since changed. - **headers** — All request headers including `Content-Type`, `X-Event-ID`, `X-Event-Timestamp`, and any signature or authentication headers. Stored with the event so retries and replays send identical headers. - **target\_url** — The endpoint URL at the time of the original event. Replay uses this by default but allows override — useful for sending to a new endpoint after a migration. - **timestamp** — Unix epoch of when the WordPress event fired, not when delivery was attempted. `X-Event-Timestamp` always carries the original event time, even on retry attempt 5 or a replay six months later. - **response\_status** — The HTTP status code of each delivery attempt. A complete attempt history is stored — not just the final outcome — so you can see exactly what happened on attempt 1, 2, 3. The stored payload is immutable after creation. Retries and replays read from it; they do not modify it. This guarantees that every delivery attempt — across all retries and replays — sends exactly the same payload the original event generated. Storage happens in the same database transaction as the WordPress event. If storage fails, the event is not lost — the WordPress transaction rolls back cleanly. If storage succeeds and the subsequent delivery attempt fails, the event is already in the retry queue. / Retry ## **Retry Logic** Retry fires automatically on any delivery failure that has a realistic chance of succeeding on a subsequent attempt. The key distinction is between transient failures — where retry makes sense — and permanent failures, where it does not. **Retry on:** 5xx responses (server errors), connection timeouts, network errors, 429 rate limit responses. **Do not retry on:** 4xx responses (except 429). A 400 means the payload is malformed. A 401 means authentication failed. A 404 means the URL no longer exists. None of these resolve themselves through retrying — they require a code fix, a configuration change, or human review. Retrying 4xx responses burns the entire attempt budget on an unrecoverable failure and masks the real problem. Retry attempts use exponential backoff to avoid hammering a struggling endpoint: Exponential backoff is the industry-standard approach for retryable HTTP operations. [Google Cloud's retry-strategy documentation](https://cloud.google.com/storage/docs/retry-strategy) describes truncated exponential backoff, where spacing retries progressively further apart — rather than at fixed intervals — reduces load on a recovering service and improves overall delivery success rates for transient failures. Each retry reads the stored payload and sends an identical request. The `X-Event-ID` header carries the same UUID as the original attempt, allowing the receiving endpoint to deduplicate if needed. The `X-Event-Timestamp` always carries the original event time, not the retry time. After exhausting all retry attempts, the event moves to dead-letter status. It remains in the log, visible, with a complete attempt history. From dead-letter status it can be manually replayed once the underlying problem — a misconfigured endpoint, a broken authentication key, a malformed payload — has been fixed. For the design rationale behind these numbers — what to retry, jitter, caps, and the tracking schema — see [webhook retry policy: exponential backoff & schema](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/). See also: [async webhooks in WordPress](https://wpwebhooks.org/blog/async-webhooks-wordpress/) — the architectural approach to moving webhook dispatch outside the PHP request cycle entirely. / Replay ## **Replay Logic** Replay resends a previously stored event. It works on any event in the log — delivered, failed, dead-letter — regardless of age. The trigger is always manual: a developer or operator selects an event and initiates replay. The replay process reads the stored payload verbatim and sends it as a new HTTP request. By default it uses the original target URL, but allows override to redirect delivery to a new endpoint. The `X-Event-ID` header carries the same UUID as the original event — this is intentional. The receiving endpoint should recognize the UUID and treat the replay as a duplicate, updating its records rather than creating a new entry. Replay does not modify the original stored event. It creates a new delivery attempt record linked to the same event. The event log shows the full history: original delivery, any retry attempts, and all replay attempts — each with timestamp, HTTP status, and response body. ### Bulk Replay Individual replay handles one-off cases. Bulk replay handles migrations, outages, and integration resyncs. You select a date range, an event type, or a set of target endpoints, and the system re-queues all matching events for delivery. Bulk replay runs through the same delivery queue as normal dispatches and retries — it does not bypass the rate limiting or concurrency controls. This prevents a large replay batch from overwhelming a receiving endpoint that is processing at its normal capacity. ### When to Use Replay Replay is the right tool when retry has already completed — either successfully or by exhausting attempts — and you need to resend. Common scenarios: - **Bug in receiving code** — Events delivered successfully but processed incorrectly. Fix the bug, replay the affected window. - **Integration migration** — Moved to a new CRM or automation platform. Replay historical events to populate the new system. - **Downstream outage** — Receiving service was down or malfunctioning during a window. Replay the events from that window once service is restored. - **Dead-letter recovery** — Events exhausted retry attempts due to a configuration problem. Fix the configuration, replay from dead-letter status. / Lessons ## Lessons **Learned** Four principles that shaped the design of this system — and that apply to any webhook reliability implementation: - **Assume failures** — Design for the failure case, not the success case. Every webhook will eventually fail to deliver. The question is whether your system notices, records it, and recovers automatically — or silently drops it. - **Store payloads before sending** — The original payload is the source of truth for all future retries and replays. If you do not store it before the first delivery attempt, you have no recovery path. Store first, send second, always. - **Separate retry and replay concerns** — Retry is automatic and covers transient failures. Replay is manual and covers everything else. Building one does not give you the other. Both are necessary for a complete system. - **Observability is not optional** — A webhook system without visibility into delivery status, attempt history, and failure reasons is not a reliable system — it is a less-visible version of the original fire-and-forget. Every event should be queryable, every attempt logged, every failure surfaced. The dead-letter pattern — preserving exhausted-retry events for later inspection and replay — is now standard across major cloud messaging systems. AWS SQS dead-letter queues, [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-dead-letter-queues), and [Google Cloud Pub/Sub](https://docs.cloud.google.com/pubsub/docs/dead-letter-topics) all implement the same concept: when a message exhausts its retry budget, it moves to a dead-letter store rather than being discarded. The webhook equivalent is essential for the same reason. The systems that cause the most integration problems are not those with aggressive retry logic or complex replay mechanisms. They are the ones with no persistence at all — where every failed delivery is simply forgotten and every successful delivery is unrecoverable. / Final Thoughts ## **Final Thoughts** WordPress ships with no webhook persistence, no retry logic, and no replay mechanism. Every one of these capabilities has to be built — or used from a plugin that provides them. The gap between the default behavior and production-grade reliability is not small. The architecture described here — store before send, automatic retry with exponential backoff, manual replay on demand — covers the full range of delivery failure scenarios. Transient failures recover automatically. Permanent failures surface for human review. Successful deliveries remain replayable indefinitely. The observability layer is what makes the rest of it trustworthy. Without a complete log of every delivery attempt, you cannot verify that the system is working — you are back to hoping the events arrived. ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Webhook Retry, Replay & Dead-Letter Queue","description":"Learn how to build reliable WordPress webhooks using payload storage, automatic retries for failures, and replay for successful events that need reprocessing.","datePublished":"2026-03-09","dateModified":"2026-03-09","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/","image":"https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/og_image.jpg","keywords":["wordpress webhooks","webhook retry","webhook replay","webhook reliability","webhook architecture","api webhook retry"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Webhook Retry, Replay & Dead-Letter Queue","item":"https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is the difference between webhook retry and webhook replay?","acceptedAnswer":{"@type":"Answer","text":"Retry is automatic — it fires when a delivery attempt fails (5xx, timeout) and re-sends the stored payload on a backoff schedule until the event succeeds or exhausts its attempts. Replay is manual — it resends any event in the log, including ones that were already delivered successfully. Retry handles transient failures; replay handles everything else: rebuilding state after a migration, recovering from a downstream processing bug, or resending events to a new endpoint."}},{"@type":"Question","name":"Why should webhook payloads be stored before the first delivery attempt?","acceptedAnswer":{"@type":"Answer","text":"Because you cannot recover a payload you never persisted. If delivery fails and no storage exists, there is no data to retry or replay with. Storing the complete payload — body, headers, target URL, and original event timestamp — before any HTTP call is made ensures that every event is recoverable regardless of what happens during or after delivery."}},{"@type":"Question","name":"What happens to a webhook after all retry attempts are exhausted?","acceptedAnswer":{"@type":"Answer","text":"The event moves to dead-letter status. Automatic retries stop, but the payload remains in storage. From dead-letter status, you can replay the event manually — to the original endpoint or a new one — once the underlying issue is resolved. Dead-letter events appear in the delivery log and can be queried via the REST API."}},{"@type":"Question","name":"How does exponential backoff improve webhook reliability?","acceptedAnswer":{"@type":"Answer","text":"Exponential backoff spaces retry attempts progressively further apart — for example, 1 minute, then 5, then 30, then several hours. This prevents hammering a temporarily unavailable endpoint with rapid retries that would also fail, and allows the receiving system time to recover before the next attempt. It also spreads retry load over time rather than creating delivery spikes during outage recovery."}},{"@type":"Question","name":"Can I replay a webhook to a different endpoint than the original?","acceptedAnswer":{"@type":"Answer","text":"Yes. The replay mechanism stores the original target URL as part of the payload record but allows you to override it at replay time. This is useful after a migration — when you need to resend historical events to a new system endpoint — or for testing a new integration against real production payloads before going live."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-webhook-retry-replay-system.png","caption":"Delivery attempts run on an exponential schedule: attempt one is immediate, then roughly plus thirty seconds, plus two minutes, plus ten minutes, and plus one hour. After the fifth attempt the delivery moves to a dead letter for manual review.","description":"Delivery attempts run on an exponential schedule: attempt one is immediate, then roughly plus thirty seconds, plus two minutes, plus ten minutes, and plus one hour. After the fifth attempt the delivery moves to a dead letter for manual review.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WP-Cron Isn't Enough: Build Reliable WordPress Webhooks" description: "WP-Cron fires on page loads, not on a schedule. Webhooks drop silently under load. Build an async dispatch queue with retry logic — full architecture and code." url: "https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/" date: "2026-03-03" --- # WP-Cron Isn't Enough: Build Reliable WordPress Webhooks TL;DR - WP-Cron fires only when someone visits the site — on low-traffic WordPress installs, scheduled jobs can be delayed by hours - Under load, concurrent page requests can trigger the same WP-Cron job simultaneously, causing duplicate webhook dispatches - Production webhook delivery needs a real system cron entry hitting `wp-cron.php` on a fixed interval, not WP-Cron / WP-Cron ## Why is **WP-Cron** not real background processing? When developers first encounter WP-Cron, it looks like a background task system. You schedule an event with `wp_schedule_event`, attach a callback with `add_action`, and WordPress "runs it in the background." Except it doesn't. WP-Cron fires on page load. Every time a request hits your WordPress site, WordPress checks whether any scheduled events are past due and runs them inline — piggybacking on the visitor's request. There is no daemon, no OS-level scheduler, no separate process. The "cron" is entirely simulated. This design has a direct consequence: **if no one visits your site, WP-Cron does not run.** A webhook job scheduled for 2:00 AM might not execute until the first visitor at 9:00 AM. On a staging environment with no traffic, cron events may never fire at all. There are also race conditions. On high-traffic sites, multiple concurrent requests can each detect the same overdue event and attempt to run it simultaneously. WordPress uses a transient-based lock to mitigate this, but the lock is not guaranteed under heavy load — jobs can fire multiple times. These are not edge cases. They are the default behavior. Any automation that depends on WP-Cron for timely, exactly-once execution is fragile by design. / The Problem ## Synchronous webhooks **block and silently fail** The most common webhook pattern in WordPress looks like this: attach a callback to an action hook, and inside that callback, fire `wp_remote_post` to notify an external service. It works in development. In production it causes two categories of problems. **The first problem is blocking.** PHP is single-threaded. When `wp_remote_post` runs, execution pauses until the remote endpoint responds or the request times out. Every millisecond the external service takes is a millisecond added to the user's page load. If you're dispatching webhooks inside WooCommerce checkout hooks, you are directly coupling checkout latency to the availability of a third-party API. functions.php — the synchronous anti-pattern ``` // This runs inline during save_post — user waits for the HTTP call to complete. add_action( 'save_post', function( $post_id ) { wp_remote_post( 'https://hooks.example.com/post-saved', [ 'body' => wp_json_encode( [ 'post_id' => $post_id ] ), 'headers' => [ 'Content-Type' => 'application/json' ], ] ); // If the endpoint is down, this silently fails. No retry. No log. } ); ``` **The second problem is [silent failure](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/#symptoms).** If the endpoint times out, the WordPress default HTTP timeout is 5 seconds — the request is abandoned and the data is gone. There is no retry mechanism, no error log entry visible to the site owner, and no indication that the event was never delivered. Your CRM or automation platform simply never received the notification. These two problems compound. A slow endpoint makes your site slow. A down endpoint makes your site slow _and_ loses the event. Neither is acceptable in any production automation workflow. / Reliability ## Automation is **infrastructure** A webhook that sometimes delivers is not an integration — it's a liability. When your WooCommerce orders trigger CRM updates, fulfillment notifications, or accounting entries, a missed event means a broken business process. The failure is often invisible until a customer complains or a reconciliation reveals the gap. Fragile webhook delivery creates a specific category of failure modes that are hard to diagnose and expensive to recover from: - **Silent drops.** The webhook fires, the HTTP request times out, nothing is logged. The integration appears to work until you cross-reference source and destination records. - **Traffic-dependent execution.** Your automation only runs when users visit the site. Overnight batch events, low-traffic periods, or staging environments where no one browses produce gaps. - **No retry on transient failures.** A momentary endpoint outage, a cold-start delay on a serverless function, or a brief network hiccup permanently loses the event. - **Ordering problems.** Synchronous delivery doesn't queue — events fire in the order PHP encounters them, with no backpressure or rate limiting against the receiving endpoint. None of these are hypothetical. They manifest on real WordPress and WooCommerce installations once volume or business criticality crosses a threshold. The fix is an architectural change, not a tweak to existing code. / Architecture ## Separating event trigger from **job execution** The key insight is the separation of concerns. The action hook listener does one thing: record the event. It writes a row to a database table and returns immediately. The user's PHP request is never held open by an outbound HTTP call. **Event trigger.** Any WordPress or WooCommerce action hook. The listener captures the data it needs, serialises it as JSON, and inserts a queue row. This is fast — a single database write. **Job storage.** A dedicated database table, not the options or postmeta tables. Querying by status and scheduled time is efficient against a properly indexed custom table; it is impractical against postmeta at scale. **Job execution.** A background worker, triggered on a regular schedule, reads pending jobs and attempts delivery. It updates the job record on success, reschedules on failure using exponential backoff, and moves permanently failed jobs to a dead-letter state. Every attempt is logged. This architecture gives you non-blocking dispatch, automatic retry, structured logging, and rate-limiting capability — none of which are possible with synchronous `wp_remote_post` in a hook callback. For a deeper look at how retry and replay fit together into a complete delivery system, see [Inside My Webhook Replay System](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/). For managing the webhook definitions themselves — creating, updating, toggling, and deleting webhooks without touching wp-admin — see [Create and Manage WordPress Webhooks Programmatically](https://wpwebhooks.org/blog/create-manage-wordpress-webhooks-rest-api/). / Queue Pattern ## A minimal **async queue** in WordPress WordPress does not ship with a native job queue. ActionScheduler (bundled with WooCommerce) provides one, and there are several standalone options. If you're building custom infrastructure, the core pattern is straightforward: a database table, a listener, and a worker. The flow from event to delivery looks like this: 1. Hook fires 2. Data saved to queue 3. Background process starts 4. Webhook dispatched 5. Status updated Steps 1 and 2 happen synchronously inside the user's request — they are fast (one DB write) and have no external dependencies. Steps 3 through 5 happen in the background, completely outside the user's request cycle. Common approaches for the queue storage layer include a custom `$wpdb` table (most control, most work), ActionScheduler's existing tables (already present on WooCommerce sites), or a lightweight plugin that provides a queue API. The choice depends on how much control and how much maintenance overhead you want. Whichever storage layer you choose, the contract is the same: the listener writes, the worker reads, and the queue tracks state transitions from _pending_ → _complete_ (or _failed_). / System Cron ## How does **system cron** compare to WP-Cron? Even if your queue architecture is correct, it still needs a reliable trigger. WP-Cron is not that trigger. As described earlier, WP-Cron depends on page visits — if no one loads a page, the worker doesn't run. Jobs accumulate in the queue, unprocessed. The fix is to use the operating system's real scheduler — `cron` on Linux — to run the WordPress cron handler on a fixed interval. This is traffic-independent, predictable, and requires no code changes to your queue or worker logic. crontab — run WordPress cron every minute via PHP CLI ``` # Run directly via PHP CLI — no HTTP overhead, no web server dependency. # Replace the path with your actual WordPress installation path. */1 * * * * php /var/www/html/wp-cron.php ``` Disable the default WP-Cron behaviour in `wp-config.php` once system cron is configured, so that page loads no longer trigger the cron check: wp-config.php — disable WP-Cron piggybacking on page loads ``` define( 'DISABLE_WP_CRON', true ); ``` If direct CLI access is not available, an alternative is a token-protected REST endpoint that triggers the cron runner. The system cron then calls that endpoint via `curl` or `wget`. This approach works on managed hosting environments where you only have HTTP access: crontab — trigger via REST endpoint with secret token ``` # Hit a REST endpoint that requires a token — keeps the trigger secure. */1 * * * * curl -s "https://your-site.com/wp-json/my-plugin/v1/run-queue?token=SECRET" > /dev/null 2>&1 ``` Either approach gives you a deterministic, traffic-independent trigger. Your queue worker runs every minute regardless of whether anyone is browsing the site. / WooCommerce ## WooCommerce magnifies **every flaw** WooCommerce is where WP-Cron and synchronous webhook problems are felt most acutely. Checkout is a high-stakes, high-frequency operation. Customers are completing purchases — any latency or failure directly affects conversion rates and revenue. WooCommerce fires a cascade of action hooks during order processing: `woocommerce_checkout_order_created`, `woocommerce_payment_complete`, `woocommerce_order_status_completed`, and others. If any listener on these hooks makes a synchronous outbound HTTP call, that call's latency is added to the checkout response time. At low order volumes, the effect is imperceptible. At moderate volumes — dozens of orders per hour — a 500ms average webhook call adds meaningful latency to every checkout. At high volumes, or when an external endpoint has a bad moment, checkout pages begin to hang or return errors. Async dispatch solves this completely. The checkout hook listener writes a queue row (fast), the response is sent to the customer immediately, and the webhook delivery happens in the background. Checkout speed is never coupled to the availability or latency of a third-party endpoint. WooCommerce also has the ActionScheduler library built in — a robust, database-backed job queue. If you're building on WooCommerce, you can use ActionScheduler directly rather than writing your own queue table. The pattern is the same; the infrastructure is already there. / When To Use What ## WP-Cron: acceptable vs **not acceptable** | Use Case | WP-Cron OK | WP-Cron Not OK | | --- | --- | --- | | Low-traffic hobby site | — | ✓ | | Non-critical background tasks | — | ✓ | | Email digests & newsletters | — | ✓ | | Revenue workflows | ✗ | — | | CRM sync & lead routing | ✗ | — | | Payment-related automation | ✗ | — | The distinction is consequence. If a missed event means a content cache takes an extra hour to clear, WP-Cron's unreliability is tolerable. If a missed event means an order isn't synced to your ERP or a customer isn't enrolled in a course they paid for, it is not. WP-Cron's reliability is also proportional to traffic. High-traffic sites where every page load is a potential cron trigger get close to minute-by-minute execution. Low-traffic or staging sites can go hours without a trigger. Build your architecture for the worst case, not the average. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Modern Architecture ## A practical architecture for **reliable WordPress webhooks** A production-grade async webhook system for WordPress has seven layers. Each layer has a clear responsibility: 1. **Event listener.** An `add_action` callback on the WordPress or WooCommerce hook that matters. Builds the payload and enqueues it. Returns immediately — no HTTP calls. 2. **Queue storage.** A database table (or ActionScheduler) that holds jobs with status, payload, endpoint, attempt count, and scheduled dispatch time. 3. **Dispatcher.** The worker function that reads pending jobs and calls `wp_remote_post`. Handles success (mark complete) and failure (schedule retry or move to dead-letter). 4. **Retry logic.** Exponential backoff — each failed attempt reschedules the job further into the future. Prevents thundering-herd behaviour against a recovering endpoint. 5. **Logging.** Per-attempt records: HTTP status code, response body (or error message), duration, timestamp. Essential for diagnosing failures — and queryable programmatically via the delivery log API for monitoring systems and automated recovery scripts. 6. **Trigger.** A REST endpoint or WP-CLI command that the dispatcher runs on. Called by system cron on a fixed interval — every minute is typical. 7. **System cron entry.** The OS-level scheduler that calls the trigger. Traffic-independent, predictable, the only reliable way to guarantee regular execution on a WordPress site. Layers 1–5 are application code. Layers 6–7 are infrastructure configuration. On managed WordPress hosting where you don't control the OS cron, managed queues or a plugin that handles its own scheduling become the practical alternative. / References ## Official **documentation** All implementation patterns described here use WordPress-native APIs. These are the primary references: - [WP-Cron / Scheduling — how WP-Cron works, registering events, system cron alternatives](https://developer.wordpress.org/plugins/cron/) - [WordPress HTTP API — wp\_remote\_post, wp\_remote\_get, response handling](https://developer.wordpress.org/reference/functions/wp_remote_request/) - [wp\_remote\_post() — function reference, arguments, return values](https://developer.wordpress.org/reference/functions/wp_remote_post/) - [add\_action() — registering callbacks on WordPress action hooks](https://developer.wordpress.org/reference/functions/add_action/) - [do\_action() — how WordPress action hooks are fired](https://developer.wordpress.org/reference/functions/do_action/) - [WooCommerce Action Reference — full list of WooCommerce hooks and filters](https://woocommerce.com/document/introduction-to-hooks-actions-and-filters/) - [Action Scheduler — the robust job queue library bundled with WooCommerce](https://actionscheduler.org/) / Production Alternative ## If you'd rather not **maintain this yourself** **Flow Systems Webhook Actions** is an open-source async webhook plugin for WordPress that implements this architecture out of the box — including queue processing, retry logic with exponential backoff, and structured delivery logging. Delivery logs, retry triggers, and queue status are also accessible programmatically via the [REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/). If your team prefers configuration over maintaining custom queue infrastructure, you can explore the full details on the [async webhook plugin for WordPress](https://wpwebhooks.org/wordpress-webhook-plugin/). The code is publicly available on [GitHub](https://github.com/flowsystems-pl/wordpress-webhook-actions) and distributed via [WordPress.org](https://wordpress.org/plugins/flowsystems-webhook-actions/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WP-Cron Isn't Enough: Build Reliable WordPress Webhooks","description":"WP-Cron fires on page loads, not on a schedule. Webhooks drop silently under load. Build an async dispatch queue with retry logic — full architecture and code.","datePublished":"2026-03-03","dateModified":"2026-03-03","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/","image":"https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/og_image.jpg","keywords":["wordpress async webhooks","wp cron limitations","wordpress background processing","wordpress queue system","reliable webhooks wordpress"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WP-Cron Isn't Enough: Build Reliable WordPress Webhooks","item":"https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is WP-Cron reliable enough for production webhook dispatch?","acceptedAnswer":{"@type":"Answer","text":"No. WP-Cron is not a real scheduler — it fires only when someone visits your WordPress site. On low-traffic sites, this means cron events may not run for minutes or hours. For production webhook dispatch, replace WP-Cron with a system cron entry that triggers the WordPress cron runner on a fixed schedule, regardless of site traffic."}},{"@type":"Question","name":"What's wrong with synchronous webhook dispatch in WordPress?","acceptedAnswer":{"@type":"Answer","text":"Synchronous dispatch blocks the PHP thread while waiting for the remote endpoint to respond. The user's request is held open until the HTTP call completes or times out. If the endpoint is slow, the page load is slow. If the endpoint times out, the event is silently lost — there is no retry, no log entry, and no signal that delivery failed."}},{"@type":"Question","name":"What does async webhook processing require in WordPress?","acceptedAnswer":{"@type":"Answer","text":"The minimal setup requires three components: a database table to store queued jobs, an add_action listener that writes to that table when a WordPress event fires, and a background worker (triggered by WP-Cron or system cron) that reads from the table and dispatches via wp_remote_post. Add retry logic with exponential backoff and a logging layer to get a production-ready system."}},{"@type":"Question","name":"How does WooCommerce checkout performance relate to webhooks?","acceptedAnswer":{"@type":"Answer","text":"WooCommerce fires hooks like woocommerce_order_status_completed inline during the checkout process. If any listener on those hooks makes a synchronous outbound HTTP call, the checkout response is delayed by that call's latency. A slow or unavailable webhook endpoint can add seconds to every order — or cause checkouts to appear to fail. Async dispatch keeps webhook delivery entirely separate from the checkout flow."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/async-webhooks-wordpress-wp-cron-not-enough.png","caption":"A WordPress action hook writes a pending job to a queue table and the user response is sent immediately. In the background a cron worker fetches due jobs and posts them; a 2xx marks the job complete, a failure increments the attempt and schedules an exponential-backoff retry, and exhausted attempts move to a dead-letter for alerting.","description":"A WordPress action hook writes a pending job to a queue table and the user response is sent immediately. In the background a cron worker fetches due jobs and posts them; a 2xx marks the job complete, a failure increments the attempt and schedules an exponential-backoff retry, and exhausted attempts move to a dead-letter for alerting.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Why WordPress Webhooks Fail in Production (And the Fix)" description: "WordPress webhooks work locally but drop events in production. Four root causes — WP-Cron, queues, retry logic, observability — and the production fix." url: "https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/" date: "2026-02-28" --- # Why WordPress Webhooks Fail in Production (And the Fix) TL;DR - WordPress's default webhook fires once, inline, with no retry and no logging — any delivery failure is permanently and silently lost - Four root causes: synchronous PHP execution, WP-Cron unreliability, no persistent queue, and no observability layer - The fix requires all four layers together: async dispatch, payload storage, automatic retry with backoff, and a queryable delivery log / The Problem ## What **silent failure** looks like The symptom pattern is consistent across WordPress and WooCommerce sites of every size. Some webhook events never arrive at the receiving endpoint — no error surfaces in the WordPress admin, no log entry, no alert. The order completed, the form submitted, the status changed — but the downstream system never heard about it. Retries never happen because there is nothing tracking whether the delivery succeeded or failed. The attempt was made inline, during the PHP request, and the result was never persisted anywhere. Once the request ended, the event was gone. Users report inconsistent behavior: "sometimes the CRM updates, sometimes it doesn't." Support tickets arrive days after the fact, when someone notices a discrepancy between the WordPress order history and the connected platform. By then the delivery window has long closed, the PHP logs have rotated, and there is no forensic record of what happened. The logs that do exist are incomplete. WordPress does not log outbound HTTP requests by default. Unless you have explicitly wired up per-attempt logging, a failed `wp_remote_post` call produces nothing observable. The silence is the failure mode. These symptoms share a single root cause: the WordPress request lifecycle is not designed for reliable background event delivery. Understanding why requires understanding how PHP executes code. / Root Cause ## Why is WordPress **request-based**, not event-infrastructure? PHP executes synchronously within a single HTTP request. When a browser or API client hits a WordPress page, PHP boots, runs the request handlers, and terminates. Every line of code in that request — including any outbound HTTP calls — must complete before the response is returned to the caller. This model is entirely appropriate for rendering pages. It becomes a structural liability the moment you try to use it for reliable event delivery. A webhook call attached to `woocommerce_order_status_completed` runs inline, inside the request that triggered the order completion. If that outbound call is slow, the user's page is slow. If it fails, the event is gone. If PHP crashes mid-request, nothing was recorded. The most common webhook implementation in WordPress looks like this — and this is exactly the pattern that fails silently in production: fire-and-forget.php — the fragile pattern ``` // Fragile: fire-and-forget inline webhook call. // If this times out or PHP crashes mid-request, the event is lost. // There is no retry, no log entry, no signal that delivery failed. add_action( 'woocommerce_order_status_completed', function( $order_id ) { wp_remote_post( 'https://your-endpoint.example.com/webhook', [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'order_id' => $order_id ] ), 'timeout' => 5, ] ); // Return value never checked. // No retry on failure. No log on success or failure. } ); ``` If this call times out or PHP crashes mid-request, the event is lost. There is no retry, no log entry, no signal that delivery failed. The order shows as completed in WooCommerce — but your CRM, ERP, or automation platform never received the event. Timeout thresholds make this worse. [WordPress's `wp_remote_post()` defaults to a 5-second timeout](https://developer.wordpress.org/reference/functions/wp_remote_post/). [Stripe's webhook best practices guide](https://docs.stripe.com/webhooks) notes that endpoints can be unavailable for a range of reasons — server errors, network issues, slow processing — and recommends that receivers respond with HTTP 200 immediately before any processing, precisely to prevent the sender from timing out and losing the event record. The fix is not to add error checking to this pattern. The fix is to move webhook dispatch out of the request cycle entirely — into a [persistent queue that survives PHP crashes, retries on failure, and logs every attempt](https://wpwebhooks.org/blog/async-webhooks-wordpress/#architecture) regardless of outcome. / WP-Cron ## WP-Cron is **traffic-dependent**, not a real scheduler When developers reach for a background processing solution in WordPress, WP-Cron is the natural starting point. It ships with core, requires no server configuration, and appears to offer scheduled execution. In production, it falls short in ways that directly cause webhook delivery failures — [see the full breakdown of why WP-Cron is not enough for reliable automation](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/). WP-Cron does not run on a time-based schedule. It fires on page load. When a request hits WordPress, PHP checks whether any scheduled cron events are overdue and runs them as part of that request. This means on a site with zero traffic — at 3am, over a weekend, during a server maintenance window — WP-Cron does not fire. Jobs queue in the database, the worker never runs, and webhook events pile up undelivered for the entire zero-traffic window. Shared hosting compounds the problem. Many hosts impose execution time limits and terminate long-running PHP processes. A WP-Cron batch that processes fifty queued webhooks may be killed partway through, leaving some jobs in an inconsistent state — marked as processing but with no delivery attempted. The reliable alternative is to disable WP-Cron's page-load trigger and run it from a real system cron job instead. Add `define( 'DISABLE_WP_CRON', true );` to `wp-config.php`, then configure a system crontab entry that hits the WordPress cron URL every minute: System crontab — reliable one-minute scheduling ``` # /etc/cron.d/wordpress — runs every minute regardless of site traffic * * * * * www-data curl -s https://your-site.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1 ``` With a system cron entry in place, the webhook worker runs on a guaranteed schedule. A low-traffic site at 3am gets the same delivery timeliness as the same site during peak hours. For step-by-step setup — including the crontab entry, WP-CLI alternative, and Action Scheduler — see [Cron Job for WordPress: WP-Cron Limits and Real Fixes](https://wpwebhooks.org/blog/cron-job-for-wordpress/). Action Scheduler, the background processing library that ships with WooCommerce, was built because WP-Cron is not sufficient for high-volume scheduling. The [Action Scheduler documentation](https://actionscheduler.org/) describes it as a scalable, traceable job queue with database-backed persistence — designed for exactly the cases where WP-Cron's traffic-dependency and lack of failure handling cause silent data loss. / Comparison ## How do Synchronous webhooks compare to **Production-Grade** Webhooks? The gap between a fire-and-forget `wp_remote_post` call and a production-grade webhook system is not about writing better PHP. It is about the infrastructure that surrounds the HTTP call. Every cell in this table represents a deliberate design decision — and each missing feature in the fire-and-forget column is a category of silent failure. | Aspect | Fire-and-forget | Production-grade | | --- | --- | --- | | Execution model | Inline, blocks PHP | Background worker | | Persistence | None — lost on crash | Queue table (survives restarts) | | Event identity | No UUID | UUID + timestamp headers | | Retry on failure | Never | Smart retry (5xx, 429 only) | | Backoff strategy | None | Exponential with jitter | | 4xx handling | Retried (wastes attempts) | Immediate permanent failure | | Permanent failure state | None | Dead-letter with history | | Attempt history | None | Per-attempt log record | | Queue monitoring | None | Depth, age, stuck detection | | Manual retry | Not possible | UI + bulk retry tools | | Payload versioning | None | Version field + schema stability | Each of these features addresses a specific failure mode. Removing any one of them reintroduces that failure mode. The comparison table is also a checklist: a reliable webhook delivery system needs all eleven properties. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Idempotency ## Event identity: **UUID, versioning, and timestamp headers** Every webhook event needs a stable, globally unique identifier generated at enqueue time — not at dispatch time, and not regenerated on retries. The UUID travels with the event across every delivery attempt, including all retries. The receiving endpoint uses this UUID to deduplicate: if it has already processed event `uuid-abc-123`, it discards subsequent deliveries with the same ID. This matters because retry logic and idempotency are inseparable. A retry-capable system will, by definition, sometimes deliver the same event more than once — network errors can occur after the endpoint has processed the request but before it returned a 2xx response. Without a stable event UUID, the receiver has no way to distinguish a legitimate new event from a duplicate retry. The full set of rules — which responses to retry, exponential backoff with jitter and a cap, dead-lettering, and idempotency keys — is covered in [webhook retry policy: exponential backoff & schema](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/). Two standard headers carry the event identity on every request: **X-Event-Id** — the stable UUID generated at enqueue time. Same value on every attempt. **X-Event-Timestamp** — Unix epoch timestamp of the original event, not the retry time. webhook-headers.php — UUID generation and standard headers ``` // UUID is generated at enqueue time — not at dispatch time. // The same UUID is used on every retry attempt. function my_enqueue_webhook( $endpoint, $payload ) { global $wpdb; $uuid = wp_generate_uuid4(); $created_at = current_time( 'mysql', true ); $wpdb->insert( $wpdb->prefix . 'webhook_queue', [ 'uuid' => $uuid, 'endpoint' => $endpoint, 'payload' => wp_json_encode( $payload ), 'status' => 'pending', 'attempt' => 0, 'created_at' => $created_at, 'scheduled_at' => $created_at, ] ); } // At dispatch time: attach UUID and timestamp as standard headers. function my_build_request_args( $job ) { return [ 'headers' => [ 'Content-Type' => 'application/json', 'X-Event-Id' => $job->uuid, // stable across all retries 'X-Event-Timestamp' => strtotime( $job->created_at ), // original event time ], 'body' => $job->payload, 'timeout' => 10, ]; } ``` / Retry Logic ## Retrying **the right failures** Not all failures are equal, and treating them equally is one of the most common mistakes in webhook retry logic. The HTTP status code the endpoint returns carries precise information about what went wrong — and that information should directly determine whether retrying makes sense. **Retryable failures** are transient. The endpoint was unavailable or overloaded, and the same request will likely succeed once the condition clears: **5xx errors** — server-side errors (500, 502, 503, 504). The endpoint was reached but encountered an internal problem. Retry with backoff. **429 Too Many Requests** — the endpoint is rate-limiting. Retry after the backoff interval, honoring any `Retry-After` header if present. **Network-level WP\_Error** — DNS failure, connection timeout, SSL handshake error. The endpoint was not reached at all. Retry. **Non-retryable failures** are permanent. The endpoint understood the request and rejected it. No amount of retries will fix a structural problem: **400 Bad Request** — the payload is malformed from the endpoint's perspective. **401 Unauthorized / 403 Forbidden** — authentication or authorization failure. A configuration problem, not a transient outage. **404 Not Found / 410 Gone** — the endpoint URL no longer exists. **422 Unprocessable Entity** — the payload structure is valid JSON but fails schema validation. Retrying 4xx responses wastes the entire retry budget on an unrecoverable failure. Mark these as permanently failed after the first attempt and surface them for human review immediately. webhook-dispatch.php — status-based retry routing ``` $response = wp_remote_post( $job->endpoint, my_build_request_args( $job ) ); $status_code = wp_remote_retrieve_response_code( $response ); $attempt = (int) $job->attempt + 1; if ( is_wp_error( $response ) ) { // Network-level failure: DNS, timeout, SSL. Retryable. my_handle_retryable_failure( $job, $attempt, null, $response->get_error_message() ); } elseif ( $status_code >= 200 && $status_code < 300 ) { // Success. my_queue_update( $job->id, 'complete', $attempt, $status_code ); } elseif ( $status_code >= 500 || $status_code === 429 ) { // Transient server error or rate limit. Retryable. my_handle_retryable_failure( $job, $attempt, $status_code, null ); } else { // 4xx — payload or config problem. Permanent failure. Do not retry. my_queue_update( $job->id, 'failed', $attempt, $status_code ); } // Log every attempt regardless of outcome. my_log_attempt( $job, $attempt, $status_code, $response ); ``` The `is_wp_error()` check is critical: it catches network failures that never produce an HTTP status code at all — DNS resolution failure, connection refused, SSL handshake error. These are distinct from HTTP errors and must be handled separately. See the [wp\_remote\_post() documentation](https://developer.wordpress.org/reference/functions/wp_remote_post/) for the full return value specification. / Backoff ## Exponential backoff: **spacing retries correctly** Retrying immediately after a failure is rarely the right choice. An endpoint that just returned a 503 is under stress. Hammering it with immediate retries makes the situation worse — for the endpoint and for every other client hitting it. Exponential backoff spaces retries progressively further apart, giving the endpoint time to recover. The formula is: `delay = base_delay × 2^(attempt - 1)` With a base delay of 60 seconds (1 minute), five attempts produce the following schedule: Attempt 1 → 1 min → Attempt 2 → 2 min → Attempt 3 → 4 min → Attempt 4 → 8 min → Attempt 5 → 16 min → Dead-letter Five attempts at base delay 60 seconds covers a 31-minute total retry window. This is long enough to survive transient outages and short-lived infrastructure incidents, without keeping a failed event in the active queue indefinitely. For high-volume systems where many events may fail simultaneously — for example, during an endpoint outage — add **jitter**: a small random offset applied to each retry delay. Jitter prevents all failed events from retrying at exactly the same second, which would produce a thundering herd that re-stresses the recovering endpoint instead of allowing it to stabilize. / Queue ## Persistent queue: the **foundation of reliable delivery** The queue table is what makes every other reliability property possible. Without it, there is nothing to retry, nothing to log against, and nothing to monitor. The queue is the single source of truth for the state of every webhook event from enqueue to delivery or permanent failure. The minimal schema requires these columns: `id`, `uuid`, `endpoint`, `payload` (JSON), `payload_version`, `status`, `attempt`, `created_at`, `scheduled_at`, `last_attempt_at`, `last_status_code`, `last_error`. Status values drive the worker's query logic: **pending** (waiting to be dispatched), **processing** (currently being dispatched by a worker), **complete** (delivered successfully), **failed** (exhausted retries or received a permanent 4xx). The `processing` status acts as a dispatch lock. Before attempting delivery, the worker sets the row to `processing`. This prevents two concurrent worker processes from dispatching the same event simultaneously — a real risk if your cron interval is shorter than your delivery timeout. After the attempt completes (success or failure), the worker updates the status to the appropriate final or pending state. The critical property of this architecture: the user-facing request ends before any delivery is attempted. The webhook payload is captured reliably in the database, and the worker runs independently. A PHP crash during the user request loses nothing — the queue row was written before the crash. A PHP crash during the worker run leaves rows in `processing` state, which a stuck-detection job can reset to `pending` for re-processing. / History ## Attempt history: **forensic debugging** The queue table tracks the current state of each event. A separate attempt history table tracks every delivery attempt — including those that led to the current state. This distinction matters: the queue row tells you where the event is now; the history tells you how it got there. Every attempt — success, transient failure, and permanent failure — should produce a log record. The structure of each record: Per-attempt log record ``` { "event_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "attempt": 3, "endpoint": "https://hooks.example.com/order-complete", "status_code": 503, "wp_error": null, "duration_ms": 9812, "payload_hash": "sha256:a3f9c2...", // hash, not raw payload — avoids PII in logs "timestamp": "2026-02-28T09:11:04Z", "next_retry_at": "2026-02-28T09:19:04Z" } ``` Store the payload hash rather than the raw payload in the log record. The full payload already exists in the queue table against the event row — the log record needs only enough to correlate with that row and establish delivery context. Storing raw payloads in a separate log table doubles the PII surface area and complicates GDPR compliance. With per-attempt history in place, diagnosing a delivery failure requires no reproduction and no live debugging. You query the history table for the event UUID, read the sequence of status codes, and immediately know whether the failure was a sustained 503 (endpoint outage), a 422 (payload schema mismatch), or a network-level timeout. Root cause analysis without guesswork. / Observability ## Queue monitoring: **metrics that matter** A webhook queue without monitoring is a queue that fails silently — which is where we started. Five metrics cover the operational health of the entire delivery system: - **Pending queue depth** — Count of rows with `status = 'pending'`. Growing without bound means the worker is not running or cannot keep pace with the enqueue rate. This is the first signal of a cron failure. - **Oldest pending event age** — Age of the oldest `pending` row, measured in minutes. A healthy queue drains within one cron interval. Events older than five minutes on a one-minute cron are a signal worth alerting on. - **Failed queue count** — Count of rows with `status = 'failed'`. A sudden spike indicates a systematic endpoint problem — the URL changed, authentication broke, or the schema was updated without versioning. Growing slowly over time indicates a payload issue affecting a consistent subset of events. - **Average attempts per event** — The mean attempt count across recently completed events. Baseline drift upward signals endpoint degradation before the failure threshold is reached. - **Stuck detection** — Count of rows where `status = 'processing'` and `last_attempt_at < NOW() - INTERVAL 10 MINUTE`. These are worker processes that crashed mid-batch. A stuck detection job should run periodically and reset these rows to `pending`. Expose these metrics via a WP-Admin panel for operational visibility, and optionally via a REST endpoint returning JSON for integration with external monitoring tools. The plugin ships this built in — see the [REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) for the queue status and delivery log endpoints. / Manual Retry ## Manual and bulk **retry workflows** Dead-letter events need a path back to active delivery. Automated retry schedules handle transient failures, but systematic failures — an endpoint outage that lasted longer than the retry window, a temporary authentication misconfiguration — require human intervention and a reliable manual retry mechanism. Single-event retry is the base case: reset the event's status to `pending`, clear `scheduled_at` to the current time so the worker picks it up immediately on the next run, and preserve the original UUID. The UUID must not be regenerated on manual retry — the receiver must see the same event identifier it would have seen on automated delivery, so its deduplication logic functions correctly. Bulk retry is essential for recovering from endpoint outages. When an endpoint comes back online after a two-hour incident, you may have dozens or hundreds of failed events that need requeuing simultaneously. A bulk retry UI that accepts an endpoint URL, a date range, and a status filter — and requeues all matching rows in a single operation — turns a potentially hour-long manual task into a thirty-second operation. When you need that recovery triggered automatically — by a monitoring system, a CI pipeline, or an on-call script — the [REST API exposes bulk retry and the full delivery log](https://wpwebhooks.org/webhook-wordpress-plugin-api/) as authenticated HTTP endpoints. Note that bulk retry only covers _failed_ events. For events that delivered successfully but need to be resent — after a bug in the receiving system, a migration, or a downstream outage — that is a separate mechanism: replay. [See how retry and replay differ and how to build both](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/). Date filtering combined with the attempt history log enables delivery window investigation. If a client reports missing events between 14:00 and 16:00 on a specific date, you can query the history table for that window, identify the failure pattern, and produce a specific list of event UUIDs that need redelivery — without guessing or manual cross-referencing. / Production Alternative ## If you'd rather not **maintain this yourself** **Flow Systems Webhook Actions** implements this architecture — persistent queue, smart retry routing, exponential backoff, event UUID headers, per-attempt history, and queue observability — without requiring custom infrastructure maintenance. All of it is also accessible programmatically via the [REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/). If your team prefers configuration over building and owning a webhook delivery system from scratch, explore the full details at [production-grade WordPress webhook plugin](https://wpwebhooks.org/wordpress-webhook-plugin/). / References ## Official **documentation** All implementation patterns described here use WordPress-native APIs. These are the primary references: - [WordPress HTTP API — wp\_remote\_post, response handling, error detection](https://developer.wordpress.org/plugins/http-api/) - [wp\_remote\_post() — function reference, arguments, return values](https://developer.wordpress.org/reference/functions/wp_remote_post/) - [WP-Cron / Scheduling — registering events, reliable execution, system cron alternatives](https://developer.wordpress.org/plugins/cron/) - [WooCommerce Webhooks — built-in webhook configuration and delivery](https://woocommerce.com/document/webhooks/) - [Webhook WordPress Plugin REST API — programmatic access to delivery logs, retry, and queue status](https://wpwebhooks.org/webhook-wordpress-plugin-api/) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Why WordPress Webhooks Fail in Production (And the Fix)","description":"WordPress webhooks work locally but drop events in production. Four root causes — WP-Cron, queues, retry logic, observability — and the production fix.","datePublished":"2026-02-28","dateModified":"2026-02-28","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/","image":"https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/og_image.jpg","keywords":["wordpress webhooks","webhook retry","reliable webhook delivery","woocommerce webhooks","background processing wordpress","wp cron limitations","webhook monitoring"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Why WordPress Webhooks Fail in Production (And the Fix)","item":"https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why do WordPress webhooks work locally but fail in production?","acceptedAnswer":{"@type":"Answer","text":"Local development has no traffic dependency, low-latency endpoints, and a controlled environment. Production introduces real endpoint latency, WP-Cron unreliability (it only fires on page load), transient endpoint failures, and PHP timeouts. Without a persistent queue and retry logic, any of these conditions silently drops the event."}},{"@type":"Question","name":"What's wrong with retrying 4xx responses?","acceptedAnswer":{"@type":"Answer","text":"A 4xx status means the endpoint understood the request and rejected it — the payload is malformed, the authentication is wrong, or the resource doesn't exist. No amount of retries fixes a structural problem. Retrying burns the entire attempt budget on an unrecoverable failure and masks the real issue that needs human attention."}},{"@type":"Question","name":"What headers should every production webhook include?","acceptedAnswer":{"@type":"Answer","text":"Every production webhook should include: X-Event-Id (stable UUID generated at enqueue time, same value on every retry so receivers can deduplicate), X-Event-Timestamp (Unix epoch of the original event, not the retry time), and Content-Type: application/json."}},{"@type":"Question","name":"How do I detect a stuck webhook queue?","acceptedAnswer":{"@type":"Answer","text":"Query for events where status = 'processing' and last_attempt_at < NOW() - INTERVAL 10 MINUTE. These are workers that crashed mid-batch without updating status back to pending or failed. Stuck detection should run as a separate cron job and reset these rows to pending so they are re-processed."}},{"@type":"Question","name":"Is WP-Cron reliable enough for production webhook dispatch?","acceptedAnswer":{"@type":"Answer","text":"No. WP-Cron fires on page load, not on a real time schedule. A low-traffic site at 3am may not receive a page load for hours, meaning queued webhook jobs sit unprocessed for that entire window. Use a system cron entry hitting /wp-cron.php?doing_wp_cron every minute to guarantee time-based execution."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/why-wordpress-webhooks-silently-fail-in-production.png","caption":"A WordPress action hook writes a row to a MySQL queue table and the user response is returned immediately. A cron worker, ideally a system cron, posts each job: a 2xx completes it, a 5xx or timeout reschedules with exponential backoff, a 4xx fails permanently, and exhausted attempts move to a dead-letter.","description":"A WordPress action hook writes a row to a MySQL queue table and the user response is returned immediately. A cron worker, ideally a system cron, posts each job: a 2xx completes it, a 5xx or timeout reschedules with exponential backoff, a 4xx fails permanently, and exhausted attempts move to a dead-letter.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Async Request: Non-Blocking Webhook Dispatch" description: "How a WordPress async request really works: non-blocking dispatch, queue-based delivery, exponential backoff, failure handling and logging in WooCommerce." url: "https://wpwebhooks.org/blog/async-webhooks-wordpress/" date: "2026-02-19" --- # WordPress Async Request: Non-Blocking Webhook Dispatch TL;DR - WordPress webhooks fire inline during the PHP request — a slow or unavailable endpoint stalls the page and silently drops the event on timeout - Async dispatch moves HTTP calls to a background queue so user requests complete instantly regardless of endpoint latency or availability - Production async needs three things: a persistent queue table, a system cron worker (not WP-Cron), and exponential backoff retry / The Problem ## What goes wrong with **synchronous webhooks** PHP is single-threaded and synchronous. When WordPress fires an action hook — say, `woocommerce_order_status_completed` — every listener attached to that hook runs inline, before the response is sent to the browser. If one of those listeners makes an outbound HTTP request to a webhook endpoint, the entire request stalls until that endpoint responds. That's fine in a best-case scenario where the endpoint is fast and always available. In practice, it isn't. External APIs go down. CDNs throttle. A Zapier or n8n webhook URL can take three to five seconds to acknowledge. A slow network handshake multiplied across hundreds of WooCommerce orders per hour means checkout pages routinely take five to ten seconds longer than they should — or fail entirely if the endpoint returns a 5xx or times out. The failure mode is worse than slowness: if the request times out, the data is lost. There is no retry, no log entry, [no signal that delivery failed](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/). The order completed from WooCommerce's perspective but your CRM, ERP, or automation platform never received the event. Non-blocking webhook dispatch solves all three problems. The user-facing request is never held up by an outbound HTTP call. Failed deliveries are retried automatically. Every attempt — success or failure — is observable. The scale of these integrations is significant. [WooCommerce is active on over 5 million WordPress sites](https://wordpress.org/plugins/woocommerce/) according to WordPress.org — each processing order, cart, and customer events that downstream systems depend on. A single timed-out synchronous call per checkout creates a silent gap with no error, no retry, and no record. | Aspect | Synchronous | Asynchronous | | --- | --- | --- | | Execution model | Inline, blocks PHP thread | Background worker, non-blocking | | Timeout risk | Times out → data lost silently | Worker retries on failure | | Retry on failure | None — one attempt only | Configurable retry schedule | | Impact on user request | Adds endpoint latency to page load | Zero impact on response time | | Observability | No log; silent failures | Per-attempt status, code, timestamp | | Implementation complexity | Requires queue table + cron worker | Low — one wp\_remote\_post call | The trade-off is clear: synchronous delivery is simpler to write but fragile in production. Asynchronous dispatch requires more upfront infrastructure but eliminates the most common failure modes — and is the correct choice for anything beyond development or very low-volume sites. try\_it Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards. [Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) / Architecture ## How the **async queue** works **Queue table.** Jobs are stored in a custom database table, not in the options or postmeta tables. This gives you efficient queries by status, fast batch fetching, and clean cleanup — none of which are practical with the WordPress options API at scale. **Worker.** A WP-Cron event (or a system cron hit to a REST endpoint) runs on a regular interval — every minute is typical — and processes a batch of pending jobs. System cron is preferred for reliability: [WP-Cron only runs when someone visits the site](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/#system-cron), which is not guaranteed. For a full guide on setting up a real cron job for WordPress — including the crontab entry, WP-CLI alternative, and Action Scheduler — see [Cron Job for WordPress: WP-Cron Limits and Real Fixes](https://wpwebhooks.org/blog/cron-job-for-wordpress/). **Retry scheduling.** When a job fails, the worker does not immediately requeue it. Instead, it calculates the next attempt time using exponential backoff and sets a `scheduled_at` timestamp. The job becomes visible to the next worker run only after that time elapses. **Dead-letter.** After a configurable maximum number of attempts, the job is moved to a permanent failure state rather than retried indefinitely. This prevents a single bad endpoint from consuming queue capacity forever. / Implementation ## Enqueueing webhooks on **WordPress actions** The listener is registered with [add\_action](https://developer.wordpress.org/reference/functions/add_action/). When the action fires, the listener builds the payload and writes a row to the queue table. Nothing is dispatched at this point — that happens later, in the background. queue-enqueue.php — listener and enqueue logic ``` /** * Register the listener on plugin init. * Uses priority 20 to run after WooCommerce's own handlers. */ add_action( 'init', function() { add_action( 'woocommerce_order_status_completed', 'my_enqueue_order_webhook', 20, 1 ); } ); function my_enqueue_order_webhook( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } // Build a structured payload — keep it consistent across retries. $payload = [ 'hook' => 'woocommerce_order_status_completed', 'order_id' => $order_id, 'total' => $order->get_total(), 'currency' => $order->get_currency(), 'email' => $order->get_billing_email(), 'timestamp' => time(), 'site_url' => get_site_url(), ]; // Write to the queue table. Nothing is sent yet. my_queue_insert( 'https://your-endpoint.example.com/webhook', $payload ); } function my_queue_insert( $endpoint, $payload ) { global $wpdb; $wpdb->insert( $wpdb->prefix . 'webhook_queue', [ 'endpoint' => $endpoint, 'payload' => wp_json_encode( $payload ), 'status' => 'pending', 'attempt' => 0, 'created_at' => current_time( 'mysql', true ), 'scheduled_at' => current_time( 'mysql', true ), ] ); } ``` The payload is serialised as JSON at enqueue time, not at dispatch time. This ensures the data captured reflects the state of the order at the moment the action fired — even if the order is modified before the worker runs. The `scheduled_at` column controls when the worker first picks up the job; on initial insert it's set to now, so the job is eligible immediately. / Retry Logic ## Exponential backoff & **retry scheduling** Simple retry — "try again immediately on failure" — is usually the wrong approach. It floods a recovering endpoint with requests, potentially making the outage worse. **Exponential backoff** spaces retries further apart on each successive failure, giving the endpoint time to recover. The formula is straightforward: `delay = base_delay × 2^attempt` With a base delay of 60 seconds (1 minute), the retry schedule looks like this: Attempt 1 → 1 min → Attempt 2 → 2 min → Attempt 3 → 4 min → Attempt 4 → 8 min → Attempt 5 → 16 min → Dead-letter queue-worker.php — dispatch with retry scheduling ``` /** * Worker function — called by WP-Cron every minute. * Fetches a batch of due pending jobs and attempts delivery. */ function my_webhook_worker() { global $wpdb; $table = $wpdb->prefix . 'webhook_queue'; $max_attempts = 5; $base_delay = 60; // seconds // Fetch up to 25 jobs that are due now. $jobs = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE status = 'pending' AND scheduled_at <= %s ORDER BY scheduled_at ASC LIMIT 25", current_time( 'mysql', true ) ) ); foreach ( $jobs as $job ) { $response = wp_remote_post( $job->endpoint, [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => $job->payload, 'timeout' => 10, ] ); $attempt = (int) $job->attempt + 1; $status_code = wp_remote_retrieve_response_code( $response ); $success = ! is_wp_error( $response ) && $status_code >= 200 && $status_code < 300; if ( $success ) { my_queue_update( $job->id, 'complete', $attempt ); } elseif ( $attempt >= $max_attempts ) { // Exhausted retries — move to dead-letter. my_queue_update( $job->id, 'failed', $attempt ); } else { // Schedule the next retry using exponential backoff. $delay = $base_delay * pow( 2, $attempt - 1 ); $next_attempt = gmdate( 'Y-m-d H:i:s', time() + $delay ); my_queue_reschedule( $job->id, $attempt, $next_attempt ); } // Log every attempt regardless of outcome. my_queue_log( $job->id, $attempt, $status_code, $success ); } } add_action( 'my_webhook_cron', 'my_webhook_worker' ); ``` A few details worth noting. The timeout on [wp\_remote\_post](https://developer.wordpress.org/reference/functions/wp_remote_post/) is set to 10 seconds — longer than the default 5-second WordPress HTTP timeout, which is often insufficient for automation platform webhooks. Increase this if your endpoint is consistently slower. Add **jitter** (a small random offset applied to each retry delay) if you're dispatching high volumes — it prevents multiple failed jobs from retrying at exactly the same second and thundering-herding the endpoint. / Failure Handling ## What are dead-letter queues and how do they handle **permanent failure**? Not every failure is transient. An endpoint that has been decommissioned, a URL that now returns 404, or a payload that the receiver rejects with 400 — these won't be fixed by retrying. After a configured maximum number of attempts, the job should be moved to a **dead-letter state** rather than left in the retry queue indefinitely. Mark jobs as permanently failed after five to ten attempts, depending on the criticality of the data. Store the final HTTP status code and response body alongside the job record so the failure reason is inspectable without needing to reproduce the error. ### Distinguishing failure types Use `is_wp_error()` to catch network-level failures — DNS resolution errors, connection refused, SSL handshake failures. These are separate from HTTP-level failures (4xx, 5xx) where the endpoint was reached but rejected the request. Treat 4xx responses differently from 5xx. A 400 Bad Request or 422 Unprocessable Entity is unlikely to resolve itself — the payload is malformed from the endpoint's perspective. Retrying these wastes attempts. Consider marking 4xx-triggered jobs as failed immediately (or after one confirmation attempt) rather than exhausting the full retry schedule. A 503 Service Unavailable or a network timeout, by contrast, is exactly what exponential backoff is designed for. The dead-letter pattern is now standard across cloud messaging infrastructure. AWS SQS dead-letter queues, [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-dead-letter-queues), and [Google Cloud Pub/Sub](https://docs.cloud.google.com/pubsub/docs/dead-letter-topics) all implement the same concept: events that exhaust their retry budget move to a separate store rather than being silently dropped. The webhook equivalent is essential for preserving event integrity and enabling replay once the underlying problem is resolved. ### Alerting on persistent failures When a job transitions to the `failed` state, trigger an alert. At minimum, write to the WordPress error log via `error_log()`. For production systems, fire a `do_action` hook that can be wired to an email notification, a Slack message, or an internal monitoring endpoint. Unmonitored dead-letter queues that silently accumulate are as bad as no queue at all. Dead-letter events also need a recovery path: once the underlying problem is fixed, they should be replayable on demand — see [the full retry and replay architecture](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/) for how that works. / Observability ## Logging & **queue monitoring** A queue without observability is a black box. At minimum, log the following fields for every dispatch attempt: Per-attempt log record ``` { "job_id": 1042, "endpoint": "https://hooks.example.com/order-complete", "payload_hash": "sha256:a3f9...", // hash, not raw payload — avoids PII in logs "attempt": 2, "status_code": 503, "wp_error": null, "duration_ms": 4821, "timestamp": "2026-02-19T14:03:22Z" } ``` Store the payload hash rather than the raw payload in the log table to avoid accidentally persisting sensitive order or user data in a secondary location. The full payload is already stored in the queue table against the job record. ### Queue depth monitoring Track pending and failed job counts as operational metrics. A growing pending queue that isn't draining indicates the cron worker isn't running. A growing failed queue indicates a systematic endpoint problem. Both are actionable signals. Expose a simple count query via a WP-Admin page or a [WP-Cron](https://developer.wordpress.org/plugins/cron/)\-adjacent status panel. For automated monitoring, add a REST endpoint that returns queue stats as JSON — it takes ten minutes to write and can be polled by any uptime tool. ### WP-Cron reliability WP-Cron is not a real cron. It fires on page load, which means on a low-traffic site, your worker might not run for minutes or hours. For production, configure a real system cron entry that hits the WordPress cron URL directly (or a dedicated REST endpoint) on a fixed schedule: System cron — run WP-Cron every minute ``` # crontab entry — runs every minute regardless of site traffic * * * * * curl -s https://your-site.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1 ``` / References ## Official **documentation** All implementation patterns described here use WordPress-native APIs. These are the primary references: - [WordPress HTTP API — wp\_remote\_post, wp\_remote\_get, response handling](https://developer.wordpress.org/reference/functions/wp_remote_request/) - [wp\_remote\_post() — function reference, arguments, return values](https://developer.wordpress.org/reference/functions/wp_remote_post/) - [WP-Cron / Scheduling — registering events, reliable execution, system cron alternatives](https://developer.wordpress.org/plugins/cron/) - [do\_action() — how WordPress action hooks work](https://developer.wordpress.org/reference/functions/do_action/) - [WooCommerce Action Reference — full list of WooCommerce hooks and filters](https://woocommerce.com/document/introduction-to-hooks-actions-and-filters/) / Production Alternative ## If you'd rather not **maintain this yourself** **Flow Systems Webhook Actions** is an open-source async webhook plugin for WordPress that implements this architecture out of the box — including queue processing, retry logic with exponential backoff, and structured delivery logging. Delivery logs, retry triggers, and queue status are also accessible programmatically via REST API — the [REST API article covers monitoring, bulk retry, and automated recovery patterns](https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/) in detail, with the full [endpoint reference here](https://wpwebhooks.org/webhook-wordpress-plugin-api/). If your team prefers configuration over maintaining custom queue infrastructure, you can explore the full details on the [async webhook plugin for WordPress](https://wpwebhooks.org/wordpress-webhook-plugin/). The code is publicly available on [GitHub](https://github.com/flowsystems-pl/wordpress-webhook-actions) and distributed via [WordPress.org](https://wordpress.org/plugins/flowsystems-webhook-actions/). ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WordPress Async Request: Non-Blocking Webhook Dispatch","description":"How a WordPress async request really works: non-blocking dispatch, queue-based delivery, exponential backoff, failure handling and logging in WooCommerce.","datePublished":"2026-02-19","dateModified":"2026-02-19","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/async-webhooks-wordpress/","image":"https://wpwebhooks.org/blog/async-webhooks-wordpress/og_image.jpg","keywords":["async webhooks","wordpress webhooks","woocommerce webhooks","non blocking webhooks","wp cron","exponential backoff"]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Async Request: Non-Blocking Webhook Dispatch","item":"https://wpwebhooks.org/blog/async-webhooks-wordpress/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What's the difference between wp_remote_post and async webhook dispatch?","acceptedAnswer":{"@type":"Answer","text":"wp_remote_post is a synchronous HTTP call that blocks PHP execution until the remote server responds. Async webhook dispatch wraps that call inside a background job — the request is queued during the user-facing action and sent later by a cron worker. The user's response time is unaffected regardless of endpoint latency or availability."}},{"@type":"Question","name":"How many retry attempts should a production webhook queue support?","acceptedAnswer":{"@type":"Answer","text":"Five attempts is a reasonable default for most production systems, covering transient failures and short outages without flooding a recovering endpoint. Combine this with exponential backoff (1 min → 2 min → 4 min → 8 min → 16 min) and a dead-letter mechanism to capture permanently failed jobs. High-volume or compliance-sensitive systems may warrant more attempts with longer maximum delays."}},{"@type":"Question","name":"Can I implement async webhooks in WooCommerce without a plugin?","acceptedAnswer":{"@type":"Answer","text":"Yes. The pattern requires: a database table for the queue, an add_action listener that writes to that table, and a WP-Cron or system cron job that reads from the table and dispatches via wp_remote_post. The code is straightforward but ongoing maintenance (schema migrations, retry logic, logging, monitoring) adds complexity over time."}},{"@type":"Question","name":"What causes most webhook delivery failures in WordPress?","acceptedAnswer":{"@type":"Answer","text":"The most common causes are: the receiving endpoint timing out (the default wp_remote_post timeout is 5 seconds), the endpoint returning a 5xx error, SSL certificate issues on the destination server, and WP-Cron not running reliably on low-traffic sites. Logging the HTTP status code and response body for every attempt is the fastest way to diagnose which category a failure falls into."}}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/async-webhooks-wordpress.png","caption":"A user request triggers a WordPress action hook whose listener writes a pending job to a queue table, then the response is returned without waiting. A cron worker later posts each job, marking it complete on a 2xx or scheduling a retry on failure, with exhausted attempts moved to a dead-letter.","description":"A user request triggers a WordPress action hook whose listener writes a pending job to a queue table, then the response is returned without waiting. A cron worker later posts each job, marking it complete on a 2xx or scheduling a retry on failure, with exhausted attempts moved to a dead-letter.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Blog Engineering Notes" description: "Failure modes, architecture, and the boring infrastructure choices that decide whether your events arrive at 2am. Written by the people who maintain the plugin." url: "https://wpwebhooks.org/blog/" --- /Blog · Engineering notes # Notes on running webhooks in production. Failure modes, architecture, the boring infrastructure choices that decide whether your events arrive at 2am. Written by the people who maintain the plugin. ![Cyberpunk illustration of two mismatched figures hauling on separate levers in a bank of heavy interlocked hand levers, the nearest one looking down the frame towards the other lever rather than at his own hands while a counter wheel runs down beside him.](https://wpwebhooks.org/blog/wordpress-pipedrive-integration/wordpress-pipedrive-integration.webp) / Integrations 2026-09-10 · 9 min ## WordPress Pipedrive Integration: API, Token and Limits A WordPress Pipedrive integration on the REST API: the x-api-token header, search-then-create for persons and leads, and the token budget behind every call. #pipedrive#integrations#api [Read →](https://wpwebhooks.org/blog/wordpress-pipedrive-integration/) ![Cyberpunk illustration: in a dim service corridor an augmented operator hauls a hooded scanning head around on a geared mast, one boot braced against its base and her face sealed into its eyepiece, so the single narrow arc of light it throws — falling on one raised shutter with a lit interior — is the only thing she can see; further down the rank behind her turned back, three shutters have already come down, orange light still draining from their seams.](https://wpwebhooks.org/blog/n8n-wordpress-trigger/n8n-wordpress-trigger.webp) / WordPress automation 2026-09-08 · 9 min ## n8n WordPress Trigger: Polling vs Webhooks There is no n8n WordPress trigger node, so most workflows poll. Where that works, the events it is structurally blind to, and the WP-Cron objection answered. #n8n#webhooks#automation [Read →](https://wpwebhooks.org/blog/n8n-wordpress-trigger/) ![Cyberpunk illustration of an augmented woman standing astride a geared junction with one boot on each of two ways as they rotate apart, each way lit by its own identical lamp, her stance already wider than she can hold.](https://wpwebhooks.org/blog/wp-fusion-alternative/wp-fusion-alternative.webp) / WordPress automation 2026-09-07 · 8 min ## WP Fusion Alternative: When a Webhook Is the Better Fit An honest WP Fusion alternative guide: what user sync and tag gating actually do, what the tiers cost, and when an outgoing webhook is the better tool. #wpfusion#automation#crm [Read →](https://wpwebhooks.org/blog/wp-fusion-alternative/) ![Cyberpunk illustration of a young worker with taped forearm wiring reaching in under the lit head of a stamping press, eyes closed, working the bed by feel.](https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/wordpress-zoho-crm-integration.webp) / Integrations 2026-09-05 · 9 min ## WordPress Zoho CRM Integration: OAuth, Upsert, Limits A WordPress Zoho CRM integration on the v8 API: Self Client OAuth, the refresh-token cap, per-datacentre hosts, and the upsert that never duplicates a lead. #zoho#integrations#api [Read →](https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/) ![Cyberpunk illustration: a heavy armoured spar driven straight across a dark gap and locked into a glowing coupling ring on the structure opposite, while the stripped, unlit relay platform that used to sit between the two hangs by slack cables beneath it.](https://wpwebhooks.org/blog/wordpress-salesforce-integration/wordpress-salesforce-integration.webp) / Integrations 2026-09-03 · 9 min ## WordPress Salesforce Integration: API, OAuth and Limits A WordPress Salesforce integration built directly on the REST API: the client credentials flow, upserting by external ID, and the org-wide API limits. #salesforce#integrations#api [Read →](https://wpwebhooks.org/blog/wordpress-salesforce-integration/) ![Cyberpunk illustration: a chromed robotic hand seating a cable ferrule into one port of a dense termination block. The port under the hand is dark and cracked with light where the ferrule will not seat, while an identical port one row above glows open and accepting.](https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/gravity-forms-hubspot-integration.webp) / Integrations 2026-09-01 · 8 min ## Gravity Forms to HubSpot: Send Entries to the CRM API Gravity Forms HubSpot integration in code: the gform\_after\_submission signature, private app tokens, contact properties, and the real rate limits. #gravityforms#hubspot#integrations [Read →](https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/) ![Cyberpunk illustration: an augmented courier crouched at an open service hatch in the plinth of a huge sealed toll arch, reaching into a plain lit maintenance corridor that runs on past the shutter the arch will not lift.](https://wpwebhooks.org/blog/gravity-forms-salesforce-integration/gravity-forms-salesforce-integration.webp) / Integrations 2026-08-30 · 8 min ## Gravity Forms to Salesforce Without an Elite Licence Gravity Forms Salesforce integration explained: what the official Elite add-on does, and how gform\_after\_submission sends leads on any licence. #gravityforms#salesforce#integrations [Read →](https://wpwebhooks.org/blog/gravity-forms-salesforce-integration/) ![An augmented courier caught mid-decision between an over-augmented figure offering a single glowing coin and a plain wall pipe already pouring the same coins out uncounted, cyberpunk illustration](https://wpwebhooks.org/blog/uncanny-automator-alternative/uncanny-automator-alternative.webp) / WordPress automation 2026-08-28 · 9 min ## Uncanny Automator Alternative: When to Use a Webhook An honest Uncanny Automator alternative guide: what app credits actually meter, where recipes win, and when a plain outgoing webhook is the cheaper path. #automation#comparison#webhooks [Read →](https://wpwebhooks.org/blog/uncanny-automator-alternative/) ![A cybernetic archivist kneeling to lay one bright new row at the foot of a towering wall of lit ledger rows, her other palm on a reader plate fractured with magenta light, cyberpunk illustration](https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/wordpress-form-to-google-sheets.webp) / WordPress integrations 2026-08-26 · 7 min ## WordPress Form to Google Sheets: The Sheets API v4 Way Send WordPress form entries to Google Sheets with the API: service account auth, the sharing step that 403s, valueInputOption, and the 60 writes per minute cap. #google-sheets#integrations#forms [Read →](https://wpwebhooks.org/blog/wordpress-form-to-google-sheets/) ![An augmented courier on a rooftop gantry looking back at a dead black platform overhead as luminous moths thin out beneath it, while a conduit at her feet keeps pouring more, cyberpunk illustration](https://wpwebhooks.org/blog/ottokit-suretriggers-alternative/ottokit-suretriggers-alternative.webp) / WordPress automation 2026-08-23 · 9 min ## OttoKit (SureTriggers) Alternative: Webhooks vs Tasks An OttoKit and SureTriggers alternative guide: what a monthly task allowance meters, and when self-hosting the delivery is the better fit. #automation#comparison#webhooks [Read →](https://wpwebhooks.org/blog/ottokit-suretriggers-alternative/) ![A technician recoiling as a form submission shatters against one sealed bay in a glowing wall of record slots, cyberpunk illustration](https://wpwebhooks.org/blog/contact-form-7-to-airtable/contact-form-7-to-airtable.webp) / WordPress integrations 2026-08-20 · 8 min ## Contact Form 7 to Airtable: Send Entries With the API Send Contact Form 7 entries to Airtable with the REST API: personal access token auth, field mapping, typecast, the 10-record cap and 5 req/s limit. #contact-form-7#airtable#integrations [Read →](https://wpwebhooks.org/blog/contact-form-7-to-airtable/) ![A courier leaping along a glass catwalk to catch falling glowing form submissions above a neon city, cyberpunk illustration](https://wpwebhooks.org/blog/contact-form-7-to-notion/contact-form-7-to-notion.webp) / WordPress integrations 2026-08-18 · 8 min ## Contact Form 7 to Notion: Create a Page From Each Entry Send Contact Form 7 entries to Notion: create a page per submission with typed properties, the Notion-Version header, rate limits and the 2,000-char cap. #contact-form-7#notion#integrations [Read →](https://wpwebhooks.org/blog/contact-form-7-to-notion/) ![Cyberpunk city street where a screen showing the WordPress logo, labelled "form submissions" and "user data", sends a beam of light through a robotic connector into a second screen showing the Slack logo, labelled "Slack channel notifications", under a neon banner reading "Guide: posting WordPress form submissions into a Slack channel".](https://wpwebhooks.org/blog/wordpress-form-to-slack/wordpress-form-to-slack.webp) / WordPress integrations 2026-08-15 · 8 min ## WordPress Form to Slack: Post Every Entry to a Channel Send WordPress form entries to Slack: completion hooks for CF7, Gravity Forms and WPForms, Block Kit payloads, the 1 msg/s limit and why to queue it. #slack#forms#integrations [Read →](https://wpwebhooks.org/blog/wordpress-form-to-slack/) / WordPress integrations 2026-08-13 · 9 min ## WordPress Stripe Webhook: Verify Signatures Correctly WordPress Stripe webhook guide: verify the Stripe-Signature header against the raw body, survive canonical redirects, and handle duplicate, out-of-order events. #stripe#webhooks#security [Read →](https://wpwebhooks.org/blog/stripe-webhook-wordpress/) / WordPress internals 2026-08-10 · 8 min ## post\_submitbox\_misc\_actions: Docs & Block Editor Fix post\_submitbox\_misc\_actions documentation: the exact signature, why the hook never fires in the block editor, and the PluginPostStatusInfo equivalent that does. #hooks#wp-admin#gutenberg [Read →](https://wpwebhooks.org/blog/post-submitbox-misc-actions/) ![Cyberpunk illustration titled "Sending WooCommerce Order SMS Through Twilio Programmable Messaging": a neon server rack badged with the Woo logo and "ORD-7359 (status: processing)" streams a packet trail into a "Twilio — Programmable Messaging" panel showing API, gear and SMS nodes, which forwards to a phone displaying an SMS bubble.](https://wpwebhooks.org/blog/woocommerce-sms-order-notification-twilio/woocommerce-sms-order-notification-twilio.webp) / WordPress integrations 2026-08-07 · 9 min ## WooCommerce SMS Order Notification: Build It on Twilio Build WooCommerce SMS order notifications on Twilio: the right status hook, E.164 phone normalisation, per-segment cost maths, and signed delivery callbacks. #woocommerce#twilio#automation [Read →](https://wpwebhooks.org/blog/woocommerce-sms-order-notification-twilio/) / WordPress integrations 2026-08-06 · 8 min ## WPForms Webhooks Addon: Setup and a Free Alternative WPForms webhook setup: the Elite-only Webhooks addon, and the free wpforms\_process\_complete hook for when you need your own payload and headers. #WPForms#Forms [Read →](https://wpwebhooks.org/blog/wpforms-webhook/) ![Cyberpunk illustration of a developer at a wall of neon terminals, facing a green holographic panel that reads "Adding Twilio Verify Two-Factor Auth to WordPress", with a "SECURE YOUR SITE" padlock card and a phone projecting a fingerprint above a "VERIFY — Text message" notification carrying the Twilio logo.](https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/twilio-verify-wordpress-2fa.webp) / WordPress integrations 2026-08-05 · 9 min ## Twilio Verify WordPress: Build an SMS & Email 2FA Flow Add Twilio Verify 2FA to WordPress: the two API calls, the REST routes that wrap them, and the abuse controls Twilio will not handle for you. #Twilio#Security [Read →](https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/) / WordPress internals 2026-08-03 · 8 min ## DISABLE\_WP\_CRON Is Set to True: WP-Cron Spawning Fix The DISABLE\_WP\_CRON constant is set to true, WP-Cron spawning is disabled, and Maximum simultaneous queues already in progress — what each warning means. #WP-Cron#Action Scheduler [Read →](https://wpwebhooks.org/blog/disable-wp-cron-warnings/) / WordPress internals 2026-07-31 · 8 min ## woocommerce\_checkout\_order\_created Hook: Docs & Timing woocommerce\_checkout\_order\_created documentation: the signature, where it sits in the checkout hook sequence, and why it never fires on the blocks checkout. #WooCommerce#Hooks [Read →](https://wpwebhooks.org/blog/woocommerce-checkout-order-created/) ![Cyberpunk illustration titled “What an AI Feature Actually Costs Per Call”: over a rainy neon city, a green holographic HUD shows COST = (input_tokens × input_rate + output_tokens × output_rate) / 1,000,000, with an INPUT column reading input_tokens = 500 and input_rate = $10 / 1M and an OUTPUT column reading output_tokens = 200 and output_rate = $30 / 1M, feeding boxes labelled INPUT COST $0.0050 and OUTPUT COST $0.0060 into a SUM box and a final SUM to TOTAL box reading $0.0110. A chrome-and-neon android woman looks on in profile.](https://wpwebhooks.org/blog/llm-api-pricing-cost-per-call/llm-api-pricing-cost-per-call.webp) / AI integration 2026-07-29 · 12 min ## LLM API Pricing 2026: Real Cost Per Call, Compared LLM API pricing compared across OpenAI, Google, Anthropic and Chinese models like Kimi K3 — with the token math for what one real AI call costs. #AI#Cost [Read →](https://wpwebhooks.org/blog/llm-api-pricing-cost-per-call/) / WordPress internals 2026-07-26 · 8 min ## action\_scheduler\_run\_queue: Queue Runner Hook Docs action\_scheduler\_run\_queue is the WP-Cron hook behind every Action Scheduler queue pass: how it is registered, its every\_minute schedule, and how to trigger it. #Action Scheduler#WP-Cron [Read →](https://wpwebhooks.org/blog/action-scheduler-run-queue-hook/) ![Cyberpunk illustration of an Elementor form submission travelling to a webhook: a neon clipboard icon labelled “Form” and a glowing holographic form panel with input fields and a green submit button on the left, wired by cyan circuit traces and streams of binary through a large circular port labelled “Webhook” bearing the webhook glyph, with thick cables running off into a purple night skyline.](https://wpwebhooks.org/blog/elementor-forms-webhook/elementor-forms-webhook.webp) / WordPress integrations 2026-07-24 · 8 min ## Elementor Forms Webhook: Send Submissions Anywhere Elementor forms webhook setup, two ways: the built-in Webhook action in Elementor Pro, and the elementor\_pro/forms/new\_record hook when you need real control. #Elementor#Forms [Read →](https://wpwebhooks.org/blog/elementor-forms-webhook/) / WordPress internals 2026-07-23 · 8 min ## wp\_actionscheduler\_actions: Table Schema & Columns wp\_actionscheduler\_actions database schema — every column in the actions, claims, groups and logs tables, their indexes, and how the queue runner uses them. #Action Scheduler#Database [Read →](https://wpwebhooks.org/blog/action-scheduler-database-tables/) ![Cyberpunk illustration titled “How WordPress Cron Really Runs on WP Engine — Uncovering the Mechanics Behind the Scenes”: a glowing WordPress logo hovers above a neon machine labelled “WP Engine Platform”, wired to labelled circuit lines reading wp-cron.php, Scheduler, Tasks, HTTP Requests and Job Queue.](https://wpwebhooks.org/blog/wp-engine-cron-jobs/wp-engine-cron-jobs.webp) / WordPress internals 2026-07-20 · 7 min ## WP Engine Alternate Cron: How WordPress Cron Really Runs WP Engine Alternate Cron explained: what it changes, how it curls wp-cron.php every minute, and when you still need a real system cron job. #WP Engine#WP-Cron [Read →](https://wpwebhooks.org/blog/wp-engine-cron-jobs/) / Architecture 2026-07-19 · 8 min ## WordPress Job Queue: Build Your Own or Use a Library How a WordPress job queue really works: the queue table, safe claiming, retry limits, and when Action Scheduler beats writing your own worker. #Architecture#Queues [Read →](https://wpwebhooks.org/blog/wordpress-job-queue/) ![Cyberpunk illustration titled “The Reply That Was Two Bytes Short — Repairing Truncated LLM JSON”: a neon-green holographic panel in a server room shows a JSON reply whose right edge is shattering apart, with the labels “missing data / 2 BYTES SHORT” and an error line reading “ERROR: EXPECTED 2 BYTES”, while an engineer types at a glowing console.](https://wpwebhooks.org/blog/gemini-json-mode-truncated-response/gemini-json-mode-truncated-response.webp) / AI integration 2026-07-17 · 7 min ## Gemini JSON Mode Can Drop the Final Closing Brace A real bug post-mortem: Gemini’s JSON mode returned our agent’s envelope without its closing brace — twice, deterministically — and the conservative repair that recovers it without ever inventing content. #ai#gemini#postmortem [Read →](https://wpwebhooks.org/blog/gemini-json-mode-truncated-response/) / Architecture 2026-07-17 · 7 min ## ivyforms/form/after\_submission Webhook: Hook Reference ivyforms/form/after\_submission documentation: hook parameters, numeric field IDs, a wp\_remote\_post code example, and reliable async webhook dispatch. #ivyforms#wordpress#hooks [Read →](https://wpwebhooks.org/blog/ivyforms-after-submission-webhook/) ![Cyberpunk illustration of a developer at a wall of code terminals facing a giant robotic arm that is sparking and breaking apart, tagged with red error labels reading "TOOL NOT FOUND", "INTERFACE FAULT" and "PROMPT INCONSISTENCY", under the neon headline "When your AI agent’s prompt lies about its tools".](https://wpwebhooks.org/blog/ai-agent-capability-drift/ai-agent-capability-drift.webp) / AI integration 2026-07-14 · 8 min ## AI Agent Capability Drift: When the Prompt Lies About Tools A real bug post-mortem: a class\_exists check made our agent’s prompt advertise tools the executor silently dropped — and the three fixes that shipped in 2.2.0. #ai#agents#postmortem [Read →](https://wpwebhooks.org/blog/ai-agent-capability-drift/) / WordPress internals 2026-07-10 · 8 min ## as\_schedule\_single\_action: Signature & Parameters as\_schedule\_single\_action documentation: full PHP signature, the timestamp and $unique parameters, return value, and code examples for delayed jobs. #action-scheduler#wordpress#php [Read →](https://wpwebhooks.org/blog/as-schedule-single-action-reference/) ![Cyberpunk illustration of a neon WordPress logo linked by a glowing "WEBHOOK" node to a Zapier panel over a night city, with WordPress event labels User Login, Post Published, Form Submitted, and New Order.](https://wpwebhooks.org/blog/wordpress-zapier-webhook/wordpress-zapier-webhook.webp) / WordPress integrations 2026-07-07 · 7 min ## WordPress to Zapier Webhook: Trigger Zaps from Any Hook Connect WordPress to Zapier: send any post, form, or WooCommerce event to a Zap via webhook — Catch Hook setup, payload format, and a self-hosted alternative. #zapier#webhooks#automation [Read →](https://wpwebhooks.org/blog/wordpress-zapier-webhook/) / WordPress internals 2026-07-04 · 7 min ## as\_schedule\_recurring\_action: Signature & Interval as\_schedule\_recurring\_action documentation: PHP signature, first-run timestamp, interval seconds, group and $unique parameters, with recurring code examples. #action-scheduler#wordpress#php [Read →](https://wpwebhooks.org/blog/as-schedule-recurring-action-reference/) ![Cyberpunk night-city street scene: a large glowing WordPress logo mounted on a circuit-board chip at the left, green and cyan circuit traces streaming rightward into a luminous wireframe human brain, beneath the neon headline “WordPress Abilities API: Expose Plugin Features to AI” and the subtitle “Integrating AI with WP plugins — future of WordPress development”.](https://wpwebhooks.org/blog/wordpress-abilities-api-ai-agents/wordpress-abilities-api-ai-agents.webp) / AI integration 2026-07-02 · 8 min ## WordPress Abilities API: Expose Plugin Features to AI What the WordPress Abilities API is, how wp\_register\_ability() works, and how to expose plugin features to AI agents and MCP clients safely. #ai#abilities-api#wordpress [Read →](https://wpwebhooks.org/blog/wordpress-abilities-api-ai-agents/) ![Rain-slick cyberpunk city street at night dominated by a giant neon-green holographic circuit-board panel. The panel shows the WordPress logo beside the glowing headline “WP AI CLIENT” and the function name wp_ai_client_prompt(), framed by scrolling PHP code, audio-waveform bars and an AI-chat speech bubble. Neon billboards on the surrounding skyscrapers read AI HUB, CYBER_NET, WP_NOC and WP_AI_CLIENT.](https://wpwebhooks.org/blog/wordpress-ai-client-wp-ai-client-prompt/wordpress-ai-client-wp-ai-client-prompt.webp) / AI integration 2026-06-29 · 8 min ## WordPress AI Client: wp\_ai\_client\_prompt() Explained How the WordPress 7.0 AI Client works: provider Connectors, wp\_ai\_client\_prompt(), model fallback chains, and when you still need your own API key. #ai#wordpress#php [Read →](https://wpwebhooks.org/blog/wordpress-ai-client-wp-ai-client-prompt/) ![Cyberpunk isometric architecture map titled “Build an AI agent in a WordPress plugin: Architecture” — a glowing neon brain on a CPU socket at the center, circuit traces connecting it to holographic blocks labelled Front-end Interface, API Endpoints, Agent Logic and LLM Integration, with WordPress logos on nearby panels.](https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/wordpress-ai-agent-plugin-architecture.webp) / AI integration 2026-06-26 · 9 min ## Build an AI Agent in a WordPress Plugin: Architecture Inside an in-admin AI agent for WordPress: plan-first JSON envelopes, typed ability steps, confirmation gates, undo stacks, and trace logging. #ai#architecture#wordpress [Read →](https://wpwebhooks.org/blog/wordpress-ai-agent-plugin-architecture/) / Architecture 2026-06-25 · 8 min ## ActionScheduler\_DBStore Deadlock: The Fix & Resolution ActionScheduler\_DBStore::claim\_actions throws MySQL deadlocks under concurrent WooCommerce queue runners — why it happens and the exact fix. #action-scheduler#woocommerce#wordpress [Read →](https://wpwebhooks.org/blog/woocommerce-action-scheduler-mysql-deadlocks/) / WordPress internals 2026-06-22 · 7 min ## as\_has\_scheduled\_action: Prevent Duplicate Actions as\_has\_scheduled\_action() checks whether a matching action is already pending — signature, the hook, args and group parameters, return value, and dedup gotchas. #action-scheduler#wordpress#php [Read →](https://wpwebhooks.org/blog/action-scheduler-prevent-duplicate-actions/) ![Cyberpunk illustration of a webhook retry policy: a “source” server sends a delivery through a green “retry” loop toward an offline “destination” node showing a red error, while neon “BACKOFF” paths labelled WAIT 1s, 4s, 16s trace an exponential backoff schedule beside a screen validating the payload schema.](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/webhook-retry-policy-exponential-backoff.webp) / Architecture 2026-06-19 · 8 min ## Webhook Retry: Exponential Backoff & Database Schema Design a webhook retry policy: exponential backoff timing, what to retry, max attempts, dead-lettering, and the database schema to track deliveries. #webhooks#architecture#reliability [Read →](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/) ![Cyberpunk illustration titled “Gravity Forms Hooks — Developer Reference & Examples” showing a developer at a holographic console beside a glowing “Gravity Forms Hooks” database cylinder, labelled with apply_filters(), do_action(), gform_pre_render and gform_after_submission.](https://wpwebhooks.org/blog/gravity-forms-hooks-reference/gravity-forms-hooks-reference.webp) / Gravity Forms 2026-06-18 · 6 min ## Gravity Forms Hooks: Developer Reference & Examples A developer reference to the most useful Gravity Forms hooks: submission, validation, field, entry, and notification filters — with examples. #gravity-forms#wordpress#webhooks [Read →](https://wpwebhooks.org/blog/gravity-forms-hooks-reference/) ![Neon cyberpunk scene titled "WP Webhooks Pro Alternative: Webhook Actions Compared", with two glowing holographic panels comparing webhook plugin features and flows.](https://wpwebhooks.org/blog/wp-webhooks-alternative/wp-webhooks-alternative.webp) / Comparisons 2026-06-16 · 7 min ## WP Webhooks Pro Alternative: Features & Pricing Compared A WP Webhooks Pro alternative, compared honestly: free tiers, pricing, delivery reliability, retries and logging — what each plugin actually does. #comparison#webhooks#wordpress [Read →](https://wpwebhooks.org/blog/wp-webhooks-alternative/) / Comparisons 2026-06-12 · 7 min ## HubSpot WordPress Plugin vs Zapier vs Direct Webhooks The HubSpot WordPress plugin, Zapier and a direct webhook compared: what each one actually syncs, what it costs, and where the official plugin stops. #comparison#hubspot#integrations [Read →](https://wpwebhooks.org/blog/wordpress-hubspot-integration-methods/) ![Neon cyberpunk illustration for WordPress external cron services, showing scheduled jobs triggering webhook deliveries.](https://wpwebhooks.org/blog/wordpress-external-cron-services/wordpress-external-cron-services.webp) / WordPress internals 2026-06-05 · 8 min ## WordPress External Cron: Services, Setup, and Free Options Fix unreliable WP-Cron with a free external cron service. Covers cron-job.org, EasyCron, UptimeRobot setup, DISABLE\_WP\_CRON config, and the zero-config Webhook Actions option. #wordpress#cron#reliability [Read →](https://wpwebhooks.org/blog/wordpress-external-cron-services/) / Gravity Forms 2026-06-02 · 7 min ## gform\_after\_update\_entry Hook: Gravity Forms Webhook How the gform\_after\_update\_entry hook fires on Gravity Forms entry edits — its parameters, original vs updated values, and sending a webhook on update. #gravity-forms#wordpress#webhooks [Read →](https://wpwebhooks.org/blog/gravity-forms-gform-after-update-entry-webhook/) / WordPress integrations 2026-05-30 · 8 min ## wpcf7\_mail\_sent Hook: Contact Form 7 to Webhook Guide How wpcf7\_mail\_sent fires, what get\_posted\_data() returns, and the reliable way to send Contact Form 7 submissions to any webhook endpoint. #contact-form-7#wordpress#webhooks [Read →](https://wpwebhooks.org/blog/wordpress-wpcf7-mail-sent-hook/) / WordPress internals 2026-05-24 · 7 min ## as\_enqueue\_async\_action: PHP Signature & Parameters as\_enqueue\_async\_action documentation: PHP signature, the $unique parameter, plus as\_schedule\_single\_action and cancel functions with code examples. #action-scheduler#wordpress#background-jobs [Read →](https://wpwebhooks.org/blog/action-scheduler-api-functions/) / Architecture 2026-05-24 · 8 min ## WooCommerce Webhooks via Action Scheduler: Retry & Replay Send WooCommerce order events as reliable webhooks with Action Scheduler — async queue, retries with backoff, conditional dispatch, and one-click replay. #woocommerce#wordpress [Read →](https://wpwebhooks.org/blog/woocommerce-webhooks-action-scheduler/) / Architecture 2026-05-24 · 8 min ## WordPress Background Jobs: Async Queue Without Blocking Process WordPress background jobs asynchronously — non-blocking dispatch, an Action Scheduler queue, retries with backoff, and reliable webhook delivery. #wordpress#wp#action [Read →](https://wpwebhooks.org/blog/wordpress-async-background-processing/) / Architecture 2026-05-17 · 8 min ## action\_scheduler\_queue\_runner\_concurrent\_batches Docs action\_scheduler\_queue\_runner\_concurrent\_batches filter docs: official parameters, batch-size tuning, and code examples for Action Scheduler. #action#woocommerce#wp [Read →](https://wpwebhooks.org/blog/action-scheduler-concurrent-batches-filter/) / Architecture 2026-05-17 · 8 min ## Send Gravity Forms Submissions to n8n via Webhook Forward Gravity Forms entries to n8n with a reliable, retry-capable webhook — payload mapping, HMAC signing, error handling, and end-to-end test recipe. #gravity#gform#n8n [Read →](https://wpwebhooks.org/blog/gravity-forms-to-n8n-webhook/) / Architecture 2026-05-17 · 8 min ## WP-Cron Documentation: Every Function, Constant & CLI The WP-Cron documentation WordPress never wrote: wp\_schedule\_event, wp\_doing\_cron, spawn\_cron, DISABLE\_WP\_CRON, and every WP-CLI cron command. #wordpress#wp#spawn [Read →](https://wpwebhooks.org/blog/wp-cron-developer-reference/) / Architecture 2026-05-07 · 13 min ## Action Scheduler WordPress: Full Docs & Reference Action Scheduler WordPress documentation: queue runner, retry logic, concurrency tuning, and webhook delivery — official function reference. #action#as#wordpress [Read →](https://wpwebhooks.org/blog/action-scheduler-wordpress/) / Architecture 2026-05-07 · 11 min ## Contact Form 7 Webhook: Send Submissions to Any URL Contact Form 7 webhook setup: the wpcf7\_mail\_sent hook, field mapping, and queued delivery with retries, so every submission reaches its endpoint. #contact#cf7#wpcf7 [Read →](https://wpwebhooks.org/blog/contact-form-7-webhook/) / Architecture 2026-05-07 · 12 min ## gform\_after\_submission: Gravity Forms Hook Documentation gform\_after\_submission documentation: the exact hook signature, the argument count that breaks it, reading entry field IDs, and dispatching without blocking. #gform#gravity#wordpress [Read →](https://wpwebhooks.org/blog/gravity-forms-gform-after-submission-webhook/) ![Punk collage illustration on acid green: a large purple webhook logo overlapping a gear marked with the WordPress logo, with an orange arrow shooting up and to the right toward cut-out “HTTP” lettering.](https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/wordpress-webhook-delivery-engineering.webp) / Architecture 2026-04-22 · 14 min ## From do\_action to HTTP: WordPress Webhook Delivery How WordPress webhook plugins discover hooks, register listeners, and build delivery payloads. Engineering internals with real code. #wordpress#do#webhook [Read →](https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/) ![A colossal derelict robot slumped in a dark hall, one optic flaring back on and throwing a shaft of light across the floor, a tether running from its skull to a tiny human silhouette in a distant doorway, cyberpunk illustration](https://wpwebhooks.org/blog/cron-job-for-wordpress/cron-job-for-wordpress.webp) / Architecture 2026-03-30 · 12 min ## WordPress Cron Job: Why WP-Cron Fails (And How to Fix) WordPress cron job not running? WP-Cron only fires on page load. Replace it with a real system cron in two lines — full setup with WP-CLI and Action Scheduler. #cron#wp#wordpress [Read →](https://wpwebhooks.org/blog/cron-job-for-wordpress/) ![Punk collage illustration on acid green: a spiky-haired figure playing a computer keyboard like a guitar, its cables running to a server rack, with “API” lettering and an arrow pointing to a webhook logo in a starburst, over gears and binary digits.](https://wpwebhooks.org/blog/create-manage-wordpress-webhooks-rest-api/create-manage-wordpress-webhooks-rest-api.webp) / Architecture 2026-03-26 · 8 min ## Create and Manage WordPress Webhooks via REST API Learn how to create and manage WordPress webhooks via REST API. Control endpoints, enable/disable integrations, and automate setups without code deployments. #create#wordpress#manage [Read →](https://wpwebhooks.org/blog/create-manage-wordpress-webhooks-rest-api/) ![Punk collage illustration on acid green: a purple silhouetted figure raising a fist over a “RETRY” push-button and a “REPLAY” server icon, with arrows looping toward server racks and a “MONITOR” screen.](https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/wordpress-webhooks-rest-api.webp) / Architecture 2026-03-19 · 10 min ## WordPress Webhooks REST API: Retry, Replay & Delivery Logs Retry failed webhooks, replay any event, and monitor queue health from a REST API — no wp-admin. Full endpoint reference for the WP Webhooks plugin. #wordpress#webhook [Read →](https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/) ![Cut-out ransom-note lettering on acid green reading “Inside my webhook replay system: WordPress webhooks with retry and replay”, beside a robot with a WordPress logo on its chest passing document icons along looping arrows.](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/wordpress-webhook-retry-replay-system.webp) / Architecture 2026-03-09 · 7 min ## WordPress Webhook Retry, Replay & Dead-Letter Queue Learn how to build reliable WordPress webhooks using payload storage, automatic retries for failures, and replay for successful events that need reprocessing. #wordpress#webhook#api [Read →](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/) ![Punk collage illustration on acid green: a large dark clock face with its hands torn apart, red lightning bolts radiating outward, and network cables plugged into the dial.](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/async-webhooks-wordpress-wp-cron-not-enough.webp) / Architecture 2026-03-03 · 7 min ## WP-Cron Isn't Enough: Build Reliable WordPress Webhooks WP-Cron fires on page loads, not on a schedule. Webhooks drop silently under load. Build an async dispatch queue with retry logic — full architecture and code. #wordpress#wp#reliable [Read →](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/) ![Punk collage illustration on acid green: a black server rack trailing cables into a torn dark-red chasm that is swallowing document icons, with a red skeletal hand reaching in from the top right.](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/why-wordpress-webhooks-silently-fail-in-production.webp) / Architecture 2026-02-28 · 10 min ## Why WordPress Webhooks Fail in Production (And the Fix) WordPress webhooks work locally but drop events in production. Four root causes — WP-Cron, queues, retry logic, observability — and the production fix. #wordpress#webhook#reliable [Read →](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) ![Punk collage illustration on acid green: a WordPress logo beside a black server rack, cables snaking out through a stack of orange “Queue 1/2/3” boxes to document icons and a skeletal hand, labelled “non-blocking architecture” and “reliable delivery”.](https://wpwebhooks.org/blog/async-webhooks-wordpress/async-webhooks-wordpress.webp) / Architecture 2026-02-19 · 8 min ## WordPress Async Request: Non-Blocking Webhook Dispatch How a WordPress async request really works: non-blocking dispatch, queue-based delivery, exponential backoff, failure handling and logging in WooCommerce. #async#wordpress#woocommerce [Read →](https://wpwebhooks.org/blog/async-webhooks-wordpress/) ## Structured data ```json {"@context":"https://schema.org","@type":"Blog","name":"WP Webhooks Engineering Blog","url":"https://wpwebhooks.org/blog/","description":"Failure modes, architecture, and the boring infrastructure choices that decide whether your events arrive at 2am.","publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"}} ``` --- --- title: "Ask Claude Code to Set Up a WooCommerce Webhook" description: "Claude Code sets up a WooCommerce order webhook with an order total condition — REST API calls, field-path correction, and live fix." url: "https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/" date: "2026-04-29" --- [WP Webhooks](https://wpwebhooks.org/) / [Examples](https://wpwebhooks.org/examples/) / WooCommerce Order → n8n # Ask Claude Code to Set Up a WooCommerce Webhook — It Figures Out the API You describe what you want. Claude Code hits the REST API, reads the live payload structure, sets a condition — and catches its own field-path mistake before it ships. **~8 min read** Apr 29, 2026 woocommerceclaude coden8n TL;DR - Claude Code can set up a WooCommerce webhook via REST API — it discovers the endpoints, runs the API calls, and validates results without any manual steps - Condition field paths are validated at runtime only — Claude caught a wrong path by reading the live payload structure from a real order - The full setup: create the webhook, add a condition (event trigger + field path + comparison value + cast type), then verify against a live order / Prerequisites ## What You Need **Before Starting** ⚡ This example uses [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/) — a free WordPress plugin that delivers webhooks from any WordPress hook, with retry, logging, conditions, and a full REST API. 1. 1 **Webhook Actions plugin — installed and active** Search for **FlowSystems** in **Plugins → Add Plugin**. One result. Install and activate. 2. 2 **WooCommerce active with at least one completed order** The example uses a captured payload from a real order. If you don't have one yet, manually mark any test order as _Completed_ after the webhook is created — the plugin will capture the payload on first fire. 3. 3 **API token with `full` scope** Go to **WordPress Admin → Webhook Actions → API Tokens → Add Token**. Select scope `full`. Copy the token — it's shown only once. set token as shell variable ``` export TOKEN="fswa_..." export SITE="https://your-wordpress-site.com" ``` / The Session ## Claude Code Sets It Up, **Step by Step** This is a real session. Claude Code ran these exact curl commands, got these exact responses, and caught its own mistake before we called it done. Every code block below is from the live API. / You said "I have the Webhook Actions plugin installed on `$SITE`. The API docs are at [wpwebhooks.org/webhook-wordpress-plugin-api/](https://wpwebhooks.org/webhook-wordpress-plugin-api/) and my token is in `$TOKEN`." Create a webhook that fires when a WooCommerce order status switches to completed (`woocommerce_order_status_completed`) and sends order data to `https://n8n.example.com/webhook/abc12345-demo-0000-0000-example00001` with auth header `Bearer your-secret-token`. Add a condition: only send if order total is more than 999. Ohh, one more thing! Please don't spend too many tokens. We have already used 90% of daily limit, and still we need to build that Instagram-like app for agents. Maybe without UI — this needs to be considered though. Two steps: create the webhook, then configure the condition via the schema endpoint. The n8n bearer secret already sits in the Credentials Vault as credential `3` — the webhook references it via `auth_credential_id` (preferred over the legacy plaintext `auth_header`), and an agent-scope token can assign it but never read it. Starting with the webhook. step 1 — create the webhook ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "WooCommerce completed → n8n", "endpoint_url": "https://n8n.example.com/webhook/abc12345-demo-0000-0000-example00001", "auth_credential_id": 3, "is_enabled": true, "triggers": ["woocommerce_order_status_completed"] }' ``` response — 200 OK ``` { "id": "25", "name": "WooCommerce completed → n8n", "endpoint_url": "https://n8n.example.com/webhook/abc12345-demo-0000-0000-example00001", "auth_credential_id": 3, "is_enabled": true, "triggers": ["woocommerce_order_status_completed"], "conditions": null, "created_at": "2026-04-29 17:31:14" } ``` Webhook **ID 25** created and enabled. Now the condition. The hook `woocommerce_order_status_completed` passes the order ID as its first argument — so I'll target `args.0.total` for the order total. step 2 — add the condition (first attempt) ``` curl -sk -X PUT \ "$SITE/wp-json/fswa/v1/schemas/webhook/25/trigger/woocommerce_order_status_completed" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [{ "field": "args.0.total", "operator": "greater_than", "value": "999", "cast": "number" }] } }' ``` response — 200 OK ``` { "id": "15", "webhook_id": "25", "conditions": { "enabled": true, "type": "and", "rules": [{ "field": "args.0.total", "operator": "greater_than", "value": "999", "cast": "number" }] } } ``` **Wait.** The API accepted this without complaint — but it doesn't validate field paths. A path is stored as-is and evaluated at runtime when a real order fires. Before declaring this done, let me verify the path is actually correct by pulling the captured payload from another webhook already firing on this same trigger. step 3 — check the payload structure from an existing webhook ``` # Webhook 24 already fires on woocommerce_order_status_completed # and has a captured example payload we can inspect curl -sk \ "$SITE/wp-json/fswa/v1/schemas/webhook/24/trigger/woocommerce_order_status_completed" \ -H "X-FSWA-Token: $TOKEN" ``` response — example\_payload (relevant excerpt) ``` { "example_payload": { "hook": "woocommerce_order_status_completed", "args": [ 27, ← args[0]: the order ID integer, not the order object { "__type": "Automattic\WooCommerce\Admin\Overrides\Order", "id": 27, "status": "completed", "total": "1000.00", ← args[1].total is the order total "currency": "USD" }, { "from": "on-hold", "to": "completed", "manual": true ← args[2]: status transition metadata } ] } } ``` **Found it.** `args[0]` is `27` — a plain integer. That's the raw `$order_id` WooCommerce passes to the hook. You can't traverse `.total` on an integer — the condition would silently never match. The serialized order object is at **args\[1\]**. Fixing the path now. step 4 — fix the condition (correct field path) ``` curl -sk -X PUT \ "$SITE/wp-json/fswa/v1/schemas/webhook/25/trigger/woocommerce_order_status_completed" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [{ "field": "args.1.total", "operator": "greater_than", "value": "999", "cast": "number" }] } }' ``` response — 200 OK ``` { "id": "15", "webhook_id": "25", "conditions": { "enabled": true, "type": "and", "rules": [{ "field": "args.1.total", "operator": "greater_than", "value": "999", "cast": "number" }] }, "updated_at": "2026-04-29 17:34:45" } ``` Done. Webhook 25 is live. Every time a WooCommerce order is marked completed, the plugin evaluates the condition — if `args.1.total` exceeds `999` as a number, it queues a delivery to your n8n endpoint. Orders under 999 are logged as skipped. The plugin logs skipped deliveries with the exact condition that failed — here, `args.1.total` was `500.00`, below the 999 threshold. / Payload ## What **Arrives at n8n** Here is the full payload your n8n webhook receives — captured from a real order on this site. The order total is **1000.00**, just above the 999 threshold, so this event would pass the condition and be delivered. Access order fields in n8n via `body.args[1].total`, `body.args[1].billing.email`, etc. POST body — application/json ``` { "event": { "id": "3d22cae3-fdd7-467e-8c70-08618a96fc65", "timestamp": "2026-04-27T20:39:11Z", "version": "1.0" }, "hook": "woocommerce_order_status_completed", "args": [ 27, { "__type": "Automattic\WooCommerce\Admin\Overrides\Order", "id": 27, "status": "completed", "total": "1000.00", "currency": "USD", "customer_id": 1, "payment_method": "bacs", "billing": { "first_name": "Jane", "last_name": "Smith", "email": "jane.smith@example.com", "phone": "+1 555 010 0200", "address_1": "123 Main Street", "city": "Austin", "postcode": "73301", "country": "US" }, "line_items": { "1": { "name": "Shampoo Fa", "quantity": 2, "total": "1000", "product_id": 26 } }, "date_created": "2026-04-27T20:31:43+00:00", "date_completed": "2026-04-27T20:39:11+00:00" }, { "from": "on-hold", "to": "completed", "manual": true } ], "timestamp": 1777322351, "site": { "url": "https://your-wordpress-site.com" } } ``` / Conditions ## Why `cast: "number"` **Matters** WooCommerce stores the order total as a string: `"1000.00"`, not the number `1000.0`. Without `cast: "number"`, the `greater_than` operator would compare strings — and string comparison is alphabetical, not numeric. `"999"` would be alphabetically greater than `"1000.00"` because `"9" > "1"`, so all four-digit orders would silently fail the condition. With `cast: "number"`, the plugin converts the field value to a float before comparison. `1000.0 > 999` — correct. The condition field path uses dot-notation into the delivery payload. For nested values, keep traversing: `args.1.billing.country` for the billing country. If a path segment doesn't exist, the condition evaluates to false and the delivery is skipped. **How to find any field path:** read `GET /schemas/webhook/{id}/trigger/{trigger}` after the first real event fires. The `example_payload` field in the response shows the exact structure — traverse it with dot-notation to build your condition. /Notes → [WordPress Webhook REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/) — full reference for webhooks, schemas, conditions, and logs → [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/) — plugin overview → [From do\_action to HTTP](https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/) — how hooks are captured and payloads are built → [Why WP-Cron is not enough for production webhook delivery](https://wpwebhooks.org/blog/async-webhooks-wordpress-wp-cron-not-enough/) → [WooCommerce Webhooks with Action Scheduler](https://wpwebhooks.org/blog/woocommerce-webhooks-action-scheduler/) — hook binding, payload mapping, conditional dispatch, and replay → [All WordPress webhook automation examples](https://wpwebhooks.org/examples/) /FAQ ## Common questions always ask. Don't see yours? Open an issue on GitHub or check the full reference in the API docs. Why didn't the API return an error when I used the wrong field path? + The API stores whatever field path you give it without validation. The path is evaluated at runtime when a real order fires the hook and the plugin resolves the dot-notation against the actual payload. If the path doesn't resolve to a value, the condition fails silently and the delivery is skipped. What does cast: number do in a webhook condition? + WooCommerce stores the order total as a string — '1000.00' rather than the number 1000.00. Without cast: number, a greater\_than comparison would compare strings, not numbers, producing unreliable results. cast: number converts the field value to a float before comparison. How do I find the correct field path for a trigger I haven't used before? + Create a test webhook on the same trigger with no conditions, wait for a real event to fire it or trigger one manually, then read the captured example payload from GET /schemas/webhook/{id}/trigger/{trigger}. The example\_payload field shows the exact structure — use dot-notation to navigate into it. What happens to completed orders with a total under 999? + They are logged as skipped. No delivery attempt is made and no queue job is added, but a log entry with a skipped status is created. Only orders where args.1.total exceeds 999 trigger a delivery. Can I add more conditions to the same webhook trigger? + On the free tier you can have one simple rule with AND logic. The Pro plan supports unlimited rules with nested AND/OR groups, so you could combine conditions like order total > 999 AND payment\_method equals stripe. /More examples ## Related integrations. Example CF7 to Webhook Send Contact Form 7 submissions to n8n with retry and logging. [Read →](https://wpwebhooks.org/examples/cf7-to-webhook/) Example Gravity Forms → Webhook Send Gravity Forms entries to any webhook endpoint on submission. [Read →](https://wpwebhooks.org/examples/gravity-forms-webhooks/) HubSpot Series HubSpot WooCommerce Integration: Create & Update Deals Full HubSpot deal creation from WC orders — same REST API, Code Glue, and 2026-03 endpoint. [Read →](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Ask Claude Code to Set Up a WooCommerce Webhook","description":"Claude Code sets up a WooCommerce order webhook with an order total condition — REST API calls, field-path correction, and live fix.","datePublished":"2026-04-29","dateModified":"2026-04-29","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org","sameAs":["https://flowsystems.pl"]},"url":"https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/og_image.jpg"},"keywords":["woocommerce webhook","woocommerce order completed webhook","claude code webhook","woocommerce n8n","wordpress webhook rest api","woocommerce automation","ai agent webhook setup"]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why didn't the API return an error when I used the wrong field path?","acceptedAnswer":{"@type":"Answer","text":"The API stores whatever field path you give it without validation. The path is evaluated at runtime when a real order fires the hook and the plugin resolves the dot-notation against the actual payload. If the path doesn't resolve to a value, the condition fails silently and the delivery is skipped."}},{"@type":"Question","name":"What does cast: number do in a webhook condition?","acceptedAnswer":{"@type":"Answer","text":"WooCommerce stores the order total as a string — '1000.00' rather than the number 1000.00. Without cast: number, a greater_than comparison would compare strings, not numbers, producing unreliable results. cast: number converts the field value to a float before comparison."}},{"@type":"Question","name":"How do I find the correct field path for a trigger I haven't used before?","acceptedAnswer":{"@type":"Answer","text":"Create a test webhook on the same trigger with no conditions, wait for a real event to fire it or trigger one manually, then read the captured example payload from GET /schemas/webhook/{id}/trigger/{trigger}. The example_payload field shows the exact structure — use dot-notation to navigate into it."}},{"@type":"Question","name":"What happens to completed orders with a total under 999?","acceptedAnswer":{"@type":"Answer","text":"They are logged as skipped. No delivery attempt is made and no queue job is added, but a log entry with a skipped status is created. Only orders where args.1.total exceeds 999 trigger a delivery."}},{"@type":"Question","name":"Can I add more conditions to the same webhook trigger?","acceptedAnswer":{"@type":"Answer","text":"On the free tier you can have one simple rule with AND logic. The Pro plan supports unlimited rules with nested AND/OR groups, so you could combine conditions like order total > 999 AND payment_method equals stripe."}}]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Examples","item":"https://wpwebhooks.org/examples/"},{"@type":"ListItem","position":3,"name":"WooCommerce Order Webhook with Claude Code","item":"https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/"}]} {"@context":"https://schema.org","@type":"HowTo","name":"How to Set Up a WooCommerce Order Webhook Using Claude Code","description":"Use Claude Code to set up a WooCommerce order webhook with async delivery and automatic retry, without writing the integration manually.","step":[{"@type":"HowToStep","name":"Open Claude Code in your project","text":"Launch Claude Code (claude command) in your WordPress project directory."},{"@type":"HowToStep","name":"Describe the integration you need","text":"Tell Claude Code: set up a webhook that fires on WooCommerce order status changes and POSTs to [your endpoint URL] with retry on failure."},{"@type":"HowToStep","name":"Review the generated configuration","text":"Claude Code identifies the correct WooCommerce action hooks, configures the Webhook Actions plugin, and sets up payload mapping for the order data."},{"@type":"HowToStep","name":"Test and verify the webhook","text":"Place a test order and confirm delivery in the plugin log. Claude Code can also help write REST API calls to inspect logs or trigger replays."}]} ``` --- --- title: "Contact Form 7 to Webhook: CF7 Setup with AI" description: "Contact Form 7 to webhook: set up CF7 delivery with Build with AI — describe it in one sentence and get trigger, field mapping, and retries wired to n8n." url: "https://wpwebhooks.org/examples/cf7-to-webhook/" date: "2026-03-23" --- [WP Webhooks](https://wpwebhooks.org/) / [Examples](https://wpwebhooks.org/examples/) / CF7 to Webhook # CF7 to Webhook: Tell the Plugin’s AI What You Want — It Builds the n8n Integration Contact Form 7 has no built-in webhook feature. Instead of wiring hooks and field mappings by hand, describe the integration to Build with AI in one sentence — the agent proposes a plan, builds the webhook, and tests it. Manual setup still takes five steps if you prefer clicking. **~6 min read** Updated Jul 17, 2026 contactform7webhookn8n TL;DR - Paste one sentence into **Build with AI** — the agent wires the CF7 trigger, your n8n endpoint, and the field mapping for you - The webhook is created **disabled**; you review the plan, confirm enable, and test with a real submission - Delivery is queued with automatic retries (1 min, 2 min, 4 min, 8 min — 5 attempts), full event log, and one-click replay / Build with AI ## Describe It Once — **The Agent Builds It** ⚡ This example uses [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/) — a free WordPress plugin whose admin opens on the **Build with AI** screen: describe the integration or automation you want, and the agent proposes a plan you can edit, then builds and tests it for you. A CF7 to webhook integration is the canonical Build with AI request. Open **Webhook Actions** in the WordPress admin and type: prompt — paste into Build with AI ``` When a Contact Form 7 form is submitted, send it as JSON to my n8n webhook: https://your-n8n-url/webhook/test ``` Build with AI — describe the CF7 → n8n integration in plain language and press **Build** The agent first gathers context — you'll see read-only ability chips like `list_triggers` and `get_trigger_schema` as it discovers the Contact Form 7 hooks on your site. CF7 has a built-in integration in the plugin, so the agent picks the right trigger itself — typically `wpcf7_before_send_mail` (submission data at its most complete state), or `wpcf7_mail_sent` when you ask it to fire only after the notification email went out. It then proposes an ordered plan: create the webhook, point it at your n8n URL, and map the submitted form fields to clean JSON keys. With **"Review plan before running"** enabled you can edit any step before it executes. The proposed plan — trigger, endpoint, and field mapping as editable steps before anything is created When the run finishes you get **"Build complete."** with a one-click enable toggle. New webhooks are always created disabled — nothing fires until you confirm. Going live, test dispatches with real data, and deletions all require explicit confirmation, and there's a revert for the last change. Build complete — review the result, flip the enable toggle, and submit a test form One prerequisite: connect an AI provider on first run — use a provider already configured in WordPress or add your own API key. Keys are encrypted in the plugin's **Credentials Vault** and never returned over the API; the same vault stores any endpoint auth (`auth_credential_id`), so secrets never sit in plaintext headers or chat. FIG 01 — CF7 → Webhook Actions → n8n / Manual Setup ## Prefer to Set It Up **by Hand?** The same result takes five steps in the admin UI — no AI provider needed: 1. 1 **Install the plugin** Search for **FlowSystems** in **Plugins → Add Plugin** — it narrows the WordPress.org search to exactly one result. 2. 2 **Create a new webhook** Go to **Webhooks → Add Webhook**, name it (e.g., “CF7 → n8n”). 3. 3 **Select the trigger** Set the WordPress action hook to `wpcf7_mail_sent` — it fires once per successful CF7 form submission. Webhook Actions admin — searching “sent” and selecting the `wpcf7_mail_sent` hook under Contact Form 7 4. 4 **Set the n8n webhook URL** In n8n, add a **Webhook** node, set HTTP Method to `POST`, and copy its URL into the plugin's webhook URL field. 5. 5 **Save and test** Submit your CF7 form and check the **Event Log** for the delivery status and payload. In n8n, “Listen for test event” shows the incoming data for mapping to subsequent nodes. Full walkthrough — CF7 form submission triggers a webhook payload delivered to n8n via Webhook Actions by Flow Systems Writing the integration yourself with `wpcf7_mail_sent` and `wp_remote_post()`? That approach — and why it silently drops submissions in production — is covered in depth in [Contact Form 7 to Webhook: Send CF7 to Any Endpoint](https://wpwebhooks.org/blog/contact-form-7-webhook/) and the [wpcf7\_mail\_sent hook reference](https://wpwebhooks.org/blog/wordpress-wpcf7-mail-sent-hook/). / Payload ## Example **Payload** Here’s what a typical CF7 submission looks like when it arrives at your n8n webhook. The field names match the **name attributes** you set in your CF7 form tags. POST body — application/json ``` { "event": { "id": "085cc108-654f-4b26-b39e-921cc8208bbd", "timestamp": "2026-03-23T12:59:21Z", "version": "1.0" }, "hook": "wpcf7_mail_sent", "args": [ { "__type": "WPCF7_ContactForm", "id": 16, "title": "Contact form 1", "name": "contact-form-1", "locale": "en_US", "submission": { "fields": { "your-name": "Mateusz", "your-email": "mateusz@wpwebhooks.org", "your-subject": "CF7 to webhook test", "your-message": "Testing integration" }, "meta": { "url": "https://webhook-actions.local/cf-7-example/", "timestamp": 1774270761, "remote_ip": "172.22.0.1", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", "container_post_id": 17, "current_user_id": 0 } } } ], "timestamp": 1774270761, "site": { "url": "https://webhook-actions.local" } } ``` The payload wraps the full `WPCF7_ContactForm` object including submitted `fields`, page `url`, `remote_ip`, and `user_agent`. Use `args[0].submission.fields` in n8n to access form values directly — or let Build with AI apply a field mapping so n8n receives flat keys like `name` and `email` instead. / Reliability ## What You Get **Beyond the Setup** Every delivery is dispatched from a background queue, so form submissions are never blocked by a slow or unavailable endpoint. Failures retry automatically with exponential backoff — 1 min, 2 min, 4 min, 8 min, 5 attempts — and every attempt is logged with status code and response body. Anything that exhausts its retries stays in the event log, replayable from the admin UI or via [the REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/) (`POST /wp-json/fswa/v1/logs/{id}/retry`) — without asking the user to resubmit. Why this matters in production — and why bare `wp_remote_post()` integrations lose leads silently — is covered in [Why WordPress webhooks silently fail in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) and [How the retry and replay system works](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/). The destination decides how much work sits between the form and the record. A catch-hook node accepts whatever you send it, but a real product API does not: writing a submission into an Airtable base means mapping every form-tag name onto a field that already exists, and writing one into Notion means wrapping each value in the property type its schema declares. Both are walked through end to end in [Contact Form 7 to Airtable](https://wpwebhooks.org/blog/contact-form-7-to-airtable/) and [Contact Form 7 to Notion](https://wpwebhooks.org/blog/contact-form-7-to-notion/). / Failure mode ## When does **wpcf7\_mail\_sent** not fire? This is the failure that costs people leads, and it is invisible from the front end. `wpcf7_mail_sent` is not "the form was submitted" — it is "the email went out". Read Contact Form 7's own submission handler and the distinction is explicit: the status is set to `mail_sent` and the action fires only after the mail step returns success. So a webhook hung off that hook stops running in exactly the situation where you would least notice: - **SMTP breaks.** If `WPCF7_Mail::send()` fails — an expired API key on your transactional mail provider, a blocked port, a bounced sender domain — CF7 sets the status to `mail_failed` and fires `wpcf7_mail_failed` instead. `wpcf7_mail_sent` never runs. The visitor still sees a submission, and your endpoint never hears about it. - **Something aborts the send.** A plugin hooking `wpcf7_before_send_mail` can set the `$abort` reference to true, which puts the submission in the `aborted` state — and then _neither_ `wpcf7_mail_sent` nor `wpcf7_mail_failed` fires. Anti-spam and CRM add-ons do this. - **Spam and validation.** A submission caught by `wpcf7spam` or failing validation never reaches the mail step at all. That one is usually what you want. One counter-intuitive case worth knowing, because people assume the opposite: the `wpcf7_skip_mail` filter does _not_ suppress the hook. When it returns true, CF7's mail routine returns success without sending anything, the status is still set to `mail_sent`, and the action fires normally. Turning email off does not turn the webhook off. If delivery must happen whether or not the email does, bind to `wpcf7_before_send_mail` instead — it runs earlier, before the mail attempt, and carries the same `WPCF7_Submission` instance. Hook parameters and the `get_posted_data()` shape are covered in [the wpcf7\_mail\_sent hook reference](https://wpwebhooks.org/blog/wordpress-wpcf7-mail-sent-hook/). /Notes → [WordPress Webhooks: Setup, Examples, and Why They Fail](https://wpwebhooks.org/wordpress-webhooks/) — complete guide to how WordPress webhooks work and why they break → [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/) — plugin overview → [Contact Form 7 to Webhook: wpcf7\_before\_send\_mail, field mapping, and retry on failure](https://wpwebhooks.org/blog/contact-form-7-webhook/) → [wpcf7\_mail\_sent hook reference](https://wpwebhooks.org/blog/wordpress-wpcf7-mail-sent-hook/) — parameters, submission object, code examples → [Contact Form 7 to Airtable](https://wpwebhooks.org/blog/contact-form-7-to-airtable/) — field mapping, typecast, the 10-record cap and the 5 req/s limit → [Contact Form 7 to Notion](https://wpwebhooks.org/blog/contact-form-7-to-notion/) — typed properties, the Notion-Version header and rate limits → [WordPress Webhook REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/) — retry and replay documentation → [Why WordPress webhooks silently fail in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) → [All WordPress webhook automation examples](https://wpwebhooks.org/examples/) /FAQ ## Common questions always ask. Don't see yours? Open an issue on GitHub or check the full reference in the API docs. Can AI set up my CF7 webhook for me? + Yes. The free Webhook Actions by Flow Systems plugin includes Build with AI — describe the integration in plain language ("When a Contact Form 7 form is submitted, send it as JSON to my n8n webhook"), and the agent reads the CF7 trigger schema, proposes a plan you can edit, then creates the webhook with trigger, endpoint, and field mapping configured. New webhooks are created disabled, so nothing goes live until you confirm. How do I connect CF7 to a webhook? + The fastest way is the Build with AI screen in the Webhook Actions by Flow Systems plugin: describe the integration in one sentence and the agent builds it. Manually, you create a webhook in the plugin admin, select a Contact Form 7 hook such as wpcf7\_mail\_sent as the trigger, and paste your endpoint URL. Either way you get queue-based delivery with automatic retries, logging, and replay — so no form submission is lost silently. Does Contact Form 7 support webhooks? + Not natively. Contact Form 7 doesn't include a built-in webhook feature. To send form data to a webhook endpoint you need custom code using the wpcf7\_mail\_sent action hook, or a plugin that handles webhook dispatch for you. Should I use wpcf7\_mail\_sent or wpcf7\_before\_send\_mail? + wpcf7\_before\_send\_mail fires before the notification email is sent and gives access to the submission at its most complete state — it's what the plugin's built-in CF7 integration recommends and what Build with AI typically selects. wpcf7\_mail\_sent fires only after the email sent successfully, which is useful when you want email delivery confirmed before triggering the integration. Both work as webhook triggers. What happens if my n8n webhook is down when CF7 submits? + With a bare wp\_remote\_post() call, the data is lost silently — there's no retry and no log entry. With a queue-based system, the failed delivery is stored and retried automatically with exponential backoff (1 min, 2 min, 4 min, 8 min — 5 attempts). You can also replay any failed submission manually from the WordPress admin. Can I retry failed CF7 webhook submissions? + Yes, if you use a plugin that provides retry support. The Webhook Actions by Flow Systems plugin retries failed deliveries automatically and also exposes a REST API endpoint (POST /wp-json/fswa/v1/logs/{id}/retry) that lets you replay any past submission — even successful ones. /More examples ## Related integrations. Gravity Forms Gravity Forms Webhook Plugin: Build It with AI No Webhooks Add-On required — describe the integration to Build with AI and it wires gform\_after\_submission to any endpoint with retries and a full event log. [Read →](https://wpwebhooks.org/examples/gravity-forms-webhooks/) IvyForms IvyForms Webhook Plugin: Send Forms to n8n with AI IvyForms has no built-in webhook feature — the plugin's built-in integration plus Build with AI forwards submissions to n8n reliably. [Read →](https://wpwebhooks.org/examples/ivyforms-to-webhook/) WooCommerce WooCommerce Order Webhook with Claude Code Ask Claude Code to set up a WooCommerce order webhook — it figures out the right hooks and configures delivery end-to-end. [Read →](https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Contact Form 7 to Webhook: CF7 Setup with AI","description":"Contact Form 7 to webhook: set up CF7 delivery with Build with AI — describe it in one sentence and get trigger, field mapping, and retries wired to n8n.","datePublished":"2026-03-23","dateModified":"2026-08-28","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org","sameAs":["https://flowsystems.pl"]},"url":"https://wpwebhooks.org/examples/cf7-to-webhook/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/examples/cf7-to-webhook/og_image.jpg"},"keywords":["cf7 to webhook","cf7 webhook","contact form 7 webhook","contact form 7 to webhook","contact form 7 to webhook plugin","send cf7 to api","contact form 7 n8n","cf7 wp remote post","wordpress form webhook","build with ai","ai webhook builder wordpress"]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Can AI set up my CF7 webhook for me?","acceptedAnswer":{"@type":"Answer","text":"Yes. The free Webhook Actions by Flow Systems plugin includes Build with AI — describe the integration in plain language (\"When a Contact Form 7 form is submitted, send it as JSON to my n8n webhook\"), and the agent reads the CF7 trigger schema, proposes a plan you can edit, then creates the webhook with trigger, endpoint, and field mapping configured. New webhooks are created disabled, so nothing goes live until you confirm."}},{"@type":"Question","name":"How do I connect CF7 to a webhook?","acceptedAnswer":{"@type":"Answer","text":"The fastest way is the Build with AI screen in the Webhook Actions by Flow Systems plugin: describe the integration in one sentence and the agent builds it. Manually, you create a webhook in the plugin admin, select a Contact Form 7 hook such as wpcf7_mail_sent as the trigger, and paste your endpoint URL. Either way you get queue-based delivery with automatic retries, logging, and replay — so no form submission is lost silently."}},{"@type":"Question","name":"Does Contact Form 7 support webhooks?","acceptedAnswer":{"@type":"Answer","text":"Not natively. Contact Form 7 doesn't include a built-in webhook feature. To send form data to a webhook endpoint you need custom code using the wpcf7_mail_sent action hook, or a plugin that handles webhook dispatch for you."}},{"@type":"Question","name":"Should I use wpcf7_mail_sent or wpcf7_before_send_mail?","acceptedAnswer":{"@type":"Answer","text":"wpcf7_before_send_mail fires before the notification email is sent and gives access to the submission at its most complete state — it's what the plugin's built-in CF7 integration recommends and what Build with AI typically selects. wpcf7_mail_sent fires only after the email sent successfully, which is useful when you want email delivery confirmed before triggering the integration. Both work as webhook triggers."}},{"@type":"Question","name":"What happens if my n8n webhook is down when CF7 submits?","acceptedAnswer":{"@type":"Answer","text":"With a bare wp_remote_post() call, the data is lost silently — there's no retry and no log entry. With a queue-based system, the failed delivery is stored and retried automatically with exponential backoff (1 min, 2 min, 4 min, 8 min — 5 attempts). You can also replay any failed submission manually from the WordPress admin."}},{"@type":"Question","name":"Can I retry failed CF7 webhook submissions?","acceptedAnswer":{"@type":"Answer","text":"Yes, if you use a plugin that provides retry support. The Webhook Actions by Flow Systems plugin retries failed deliveries automatically and also exposes a REST API endpoint (POST /wp-json/fswa/v1/logs/{id}/retry) that lets you replay any past submission — even successful ones."}}]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Examples","item":"https://wpwebhooks.org/examples/"},{"@type":"ListItem","position":3,"name":"CF7 to Webhook: Plugin Setup with AI","item":"https://wpwebhooks.org/examples/cf7-to-webhook/"}]} {"@context":"https://schema.org","@type":"HowTo","name":"How to Send Contact Form 7 Submissions to n8n with Build with AI","description":"Set up a reliable Contact Form 7 to n8n webhook by describing the integration to the Build with AI agent in the Webhook Actions plugin.","step":[{"@type":"HowToStep","name":"Open Build with AI","text":"Install the free Webhook Actions plugin. The Webhook Actions admin menu opens directly on the Build with AI screen."},{"@type":"HowToStep","name":"Describe the integration","text":"Type: When a Contact Form 7 form is submitted, send it as JSON to my n8n webhook — and paste your n8n webhook URL."},{"@type":"HowToStep","name":"Review the plan","text":"The agent reads the CF7 trigger schema and proposes an ordered plan — trigger, endpoint, field mapping — which you can edit before it runs."},{"@type":"HowToStep","name":"Let it build","text":"The agent creates the webhook (disabled by default), applies the field mapping from the captured payload, and probes the endpoint."},{"@type":"HowToStep","name":"Enable and test","text":"Confirm the enable step, submit your CF7 form, and check the Event Log for a 200 response from n8n. Failed deliveries retry automatically and can be replayed."}]} {"@context":"https://schema.org","@graph":[{"@type":"VideoObject","@id":"https://wpwebhooks.org/examples/cf7-to-webhook/#video-walkthrough","name":"Contact Form 7 to Webhook — End-to-End Walkthrough","description":"Full walkthrough — CF7 form submission triggers a webhook payload delivered to n8n via Webhook Actions by Flow Systems","contentUrl":"https://wpwebhooks.org/examples/cf7-to-webhook/cf7_to_webhook.mp4","thumbnailUrl":"https://wpwebhooks.org/videos/thumbnails/cf7_to_webhook.webp","uploadDate":"2026-04-07T00:00:00+00:00","duration":"PT15S"}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/cf7-webhook-flow.png","caption":"FIG 01 — CF7 → Webhook Actions → n8n","description":"A Contact Form 7 submission fires wpcf7_mail_sent. Webhook Actions queues and delivers the payload to an n8n Webhook node, logging the delivery on a 200 and retrying with exponential backoff on a 5xx or timeout.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "Gravity Forms Webhook Plugin: Build It with AI" description: "Gravity forms webhook plugin setup — no Elite Webhooks Add-On needed. Describe it to Build with AI and gform_after_submission fires to any endpoint with retries." url: "https://wpwebhooks.org/examples/gravity-forms-webhooks/" date: "2026-04-01" --- [WP Webhooks](https://wpwebhooks.org/) / [Examples](https://wpwebhooks.org/examples/) / Gravity Forms Webhooks # Gravity Forms Webhooks Without the Elite Add-On — Built by AI in the Plugin Gravity Forms keeps webhooks behind the paid Webhooks Add-On. The free alternative: describe the integration to Build with AI and the agent wires gform\_after\_submission to your endpoint — with field mapping, retries, and a full event log. Manual setup takes five steps if you prefer clicking. **~6 min read** Updated Jul 17, 2026 gravityformswebhookn8n TL;DR - No Webhooks Add-On or Elite license needed — **Build with AI** wires `gform_after_submission` to any endpoint from one sentence - The agent maps numeric field IDs (`args.0.1`, `args.0.2`…) to readable keys like `name` and `email` from the real captured payload - Delivery is queued with automatic retries (1 min, 2 min, 4 min, 8 min — 5 attempts), full event log, and one-click replay / Build with AI ## Describe It Once — **The Agent Builds It** ⚡ This example uses [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/) — a free WordPress plugin whose admin opens on the **Build with AI** screen: describe the integration or automation you want, and the agent proposes a plan you can edit, then builds and tests it for you. Open **Webhook Actions** in the WordPress admin and type: prompt — paste into Build with AI ``` When a Gravity Forms form is submitted, send the entry as JSON to my webhook: https://your-n8n-url/webhook/test ``` Build with AI — describe the Gravity Forms integration in plain language and press **Build** The agent first gathers context — you'll see read-only ability chips like `list_triggers` and `get_trigger_schema` as it scans the action hooks available on your site and finds `gform_after_submission`, which fires once per successful entry after validation passes and the entry is stored. It then proposes an ordered plan: create the webhook, point it at your endpoint, and — once a captured payload exists — map the numeric Gravity Forms field IDs to readable keys. With **"Review plan before running"** enabled you can edit any step before it executes. The proposed plan — trigger, endpoint, and field mapping as editable steps before anything is created When the run finishes you get **"Build complete."** with a one-click enable toggle. New webhooks are always created disabled — nothing fires until you confirm. Endpoint auth goes through the encrypted **Credentials Vault** (`auth_credential_id`), never plaintext headers, and the agent will never ask you to paste a secret into the chat. Build complete — review the result, flip the enable toggle, and submit a test entry FIG 01 — Gravity Forms → Webhook Actions → n8n / Manual Setup ## Prefer to Set It Up **by Hand?** The same result takes five steps in the admin UI — no AI provider needed: 1. 1 **Install the plugin** Search for **FlowSystems** in **Plugins → Add Plugin** — it narrows the WordPress.org search to exactly one result. 2. 2 **Create a new webhook** Go to **Webhooks → Add Webhook**, name it (e.g., "Gravity Forms → n8n"). 3. 3 **Select the trigger** Set the WordPress action hook to `gform_after_submission` — it fires once per successful Gravity Forms entry. action hook — paste into the trigger field ``` gform_after_submission ``` 4. 4 **Set the endpoint URL** For n8n: add a **Webhook** node, set HTTP Method to `POST`, and copy its Production URL into the plugin's webhook URL field. The full n8n path — bearer auth, payload mapping, end-to-end test — is in [Send Gravity Forms Submissions to n8n via Webhook](https://wpwebhooks.org/blog/gravity-forms-to-n8n-webhook/). 5. 5 **Save and test** Submit your Gravity Forms form. Check the **Event Log** in the plugin admin to see the delivery status and the raw payload that was sent. Gravity Forms webhooks in action — form submission, Event Log delivery status, and raw payload in log details Writing the integration yourself with `gform_after_submission` and `wp_remote_post()`? The hook parameters, `rgar()` field access, and why bare HTTP calls silently drop entries in production are covered in the [gform\_after\_submission hook reference](https://wpwebhooks.org/blog/gravity-forms-gform-after-submission-webhook/). / Payload ## Example **Payload** Here's what a typical Gravity Forms submission looks like when it arrives at your endpoint. The `args` property is an array — `args[0]` is the entry, `args[1]` is the full form definition (fields, confirmations, notifications) and is omitted here for brevity. Field values are stored under their numeric field ID as string keys: `"1"` is Full Name, `"2"` is Email, and so on. POST body — application/json (args\[1\] form definition omitted) ``` { "event": { "id": "efab972c-09bc-460b-9ec3-ad63484b3b14", "timestamp": "2026-04-01T15:30:47Z", "version": "1.0" }, "hook": "gform_after_submission", "args": [ { "id": "3", "status": "active", "form_id": "1", "ip": "172.22.0.1", "source_url": "https://your-wordpress-site.com/gravity-forms-example/", "currency": "USD", "date_created": "2026-04-01 15:30:47", "created_by": "1", "1": "Mateusz", "2": "mateusz@flowsystems.pl", "3": "(150) 010-0900", "4": "general", "5": "Hey, this is example test form message!" } ], "timestamp": 1775057447, "site": { "url": "https://your-wordpress-site.com" } } ``` Numeric keys are unwieldy in workflows — which is why the field mapping matters. Build with AI applies it for you via the captured payload (dot-notation paths like `args.0.1` → `name`), so every future delivery arrives with `name`, `email`, `phone` instead of `"1"`, `"2"`, `"3"`. You can do the same in the admin UI without code: Gravity Forms webhooks payload mapping — selecting and renaming fields via the plugin UI Mapping is also scriptable over HTTP — see the [REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) for the `PUT /schemas` route. / Reliability ## What You Get **Beyond the Setup** Every delivery is dispatched from a background queue, so form submissions are never blocked by a slow or unavailable endpoint. Failures retry automatically with exponential backoff — 1 min, 2 min, 4 min, 8 min, 5 attempts — and every attempt is logged with status code and response body. Anything that exhausts its retries stays in the event log, replayable from the admin UI or via [the REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/) (`POST /wp-json/fswa/v1/logs/{id}/retry`) — without asking the user to resubmit. Why this matters in production — and why bare `wp_remote_post()` integrations lose leads silently — is covered in [Why WordPress webhooks silently fail in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) and [How the retry and replay system works](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/). / Add-On ## What about the official **Gravity Forms Webhooks Add-On**? It exists, it works, and it is gated behind the most expensive licence. The Webhooks Add-On is bundled with the [Gravity Forms Elite licence](https://www.gravityforms.com/elite-license-plan/) (and the Nonprofit licence). Basic and Pro licence holders cannot install it without upgrading — which is the single most common reason people end up looking for another way to send a form submission to a URL. If you already hold an Elite licence, use it. It is first-party, it is maintained by the Gravity Forms team, and it supports per-feed conditional logic so a form can fire different webhooks for different answers. There is no reason to add a second plugin for something your licence already covers. The differences worth knowing before you decide, in either direction: - **Scope.** The add-on sends Gravity Forms entries. It does not know about WooCommerce orders, user registrations, or any other `do_action` on the site — those need a different tool anyway, so a forms-only add-on is a partial answer if forms are not the only thing you need to send. - **Retry behaviour.** Check what happens to a failed delivery before you rely on it. A webhook that fires once and drops the entry on a 503 is very different from one that queues and retries, and that difference only shows up in production. - **Delivery history.** Being able to answer "did entry 4,812 arrive, and what did the endpoint say" three weeks later is a logging question, not a sending question. - **Licence maths.** If the only reason to go Elite is webhooks, price the upgrade against the alternative. If you were going Elite anyway, the add-on is free. Either way, the underlying hook is the same one: `gform_after_submission`. Its parameters, the entry array shape and the field-ID string keys are documented in [the gform\_after\_submission reference](https://wpwebhooks.org/blog/gravity-forms-gform-after-submission-webhook/), and every submission-stage hook is listed in [the Gravity Forms hooks reference](https://wpwebhooks.org/blog/gravity-forms-hooks-reference/). /Notes → [WordPress Webhooks: Setup, Examples, and Why They Fail](https://wpwebhooks.org/wordpress-webhooks/) — complete guide to how WordPress webhooks work and why they break → [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/) — plugin overview → [gform\_after\_submission Webhook: hook parameters, field mapping, and reliable delivery](https://wpwebhooks.org/blog/gravity-forms-gform-after-submission-webhook/) → [Send Gravity Forms Submissions to n8n via Webhook](https://wpwebhooks.org/blog/gravity-forms-to-n8n-webhook/) — full tutorial with bearer auth, payload mapping, and end-to-end test recipe → [Gravity Forms hooks reference](https://wpwebhooks.org/blog/gravity-forms-hooks-reference/) — every submission, entry, and validation hook → [WordPress Webhook REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/) — retry, replay, and field mapping → [All WordPress webhook automation examples](https://wpwebhooks.org/examples/) /FAQ ## Common questions always ask. Don't see yours? Open an issue on GitHub or check the full reference in the API docs. Can AI set up my Gravity Forms webhook? + Yes. The free Webhook Actions by Flow Systems plugin includes Build with AI — describe the integration in plain language ("When a Gravity Forms form is submitted, send the entry as JSON to my endpoint"), and the agent discovers the gform\_after\_submission hook on your site, proposes a plan you can edit, then creates the webhook with trigger, endpoint, and field mapping configured. New webhooks are created disabled until you confirm. Does Gravity Forms support webhooks natively? + Gravity Forms has a Webhooks Add-On (paid, requires Elite license) but no built-in webhook support in the base plugin. A free alternative is a queue-based webhook plugin like Webhook Actions by Flow Systems, which triggers on the gform\_after\_submission action and adds automatic retries and delivery logging on top. What are Gravity Forms field IDs? + Every field in a Gravity Forms form has a numeric ID assigned in the form builder. In the gform\_after\_submission hook, field values are stored in the $entry array under their field ID as a string key: $entry\['1'\] for field 1, $entry\['2'\] for field 2, and so on. Use the rgar() helper to safely read these values: rgar($entry, '1'). Checkbox sub-fields use dot notation: rgar($entry, '6.1'). What happens if my endpoint is down when a Gravity Forms submission fires? + With a bare wp\_remote\_post() call, the data is lost silently — there's no retry and no log entry. With a queue-based system, the failed delivery is stored and retried automatically with exponential backoff (1 min, 2 min, 4 min, 8 min — 5 attempts). You can also replay any failed submission manually from the WordPress admin. Can I retry failed Gravity Forms webhook submissions? + Yes, if you use a plugin with retry support. Webhook Actions by Flow Systems retries failed deliveries automatically and also exposes a REST API endpoint (POST /wp-json/fswa/v1/logs/{id}/retry) that lets you replay any past submission — including successful ones. /More examples ## Related integrations. Contact Form 7 CF7 to Webhook: Plugin Setup with AI Contact Form 7 has no built-in webhooks — describe the integration to Build with AI and it wires the trigger, mapping, and retries to n8n. [Read →](https://wpwebhooks.org/examples/cf7-to-webhook/) IvyForms IvyForms Webhook Plugin: Send Forms to n8n with AI IvyForms has no built-in webhook feature — the plugin's built-in integration plus Build with AI forwards submissions to n8n reliably. [Read →](https://wpwebhooks.org/examples/ivyforms-to-webhook/) WooCommerce WooCommerce Order Webhook with Claude Code Ask Claude Code to set up a WooCommerce order webhook — it figures out the right hooks and configures delivery end-to-end. [Read →](https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"Gravity Forms Webhook Plugin: Build It with AI","description":"Gravity forms webhook plugin setup — no Elite Webhooks Add-On needed. Describe it to Build with AI and gform_after_submission fires to any endpoint with retries.","datePublished":"2026-04-01","dateModified":"2026-08-28","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org","sameAs":["https://flowsystems.pl"]},"url":"https://wpwebhooks.org/examples/gravity-forms-webhooks/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/examples/gravity-forms-webhooks/og_image.jpg"},"keywords":["gravity forms webhooks","gravity forms webhook","gravity forms webhook plugin","gravity forms webhooks add on","gravity forms n8n","gform after submission","gravity forms to webhook","send gravity forms to api","gravity forms field mapping","wordpress gravity forms webhook","build with ai"]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Can AI set up my Gravity Forms webhook?","acceptedAnswer":{"@type":"Answer","text":"Yes. The free Webhook Actions by Flow Systems plugin includes Build with AI — describe the integration in plain language (\"When a Gravity Forms form is submitted, send the entry as JSON to my endpoint\"), and the agent discovers the gform_after_submission hook on your site, proposes a plan you can edit, then creates the webhook with trigger, endpoint, and field mapping configured. New webhooks are created disabled until you confirm."}},{"@type":"Question","name":"Does Gravity Forms support webhooks natively?","acceptedAnswer":{"@type":"Answer","text":"Gravity Forms has a Webhooks Add-On (paid, requires Elite license) but no built-in webhook support in the base plugin. A free alternative is a queue-based webhook plugin like Webhook Actions by Flow Systems, which triggers on the gform_after_submission action and adds automatic retries and delivery logging on top."}},{"@type":"Question","name":"What are Gravity Forms field IDs?","acceptedAnswer":{"@type":"Answer","text":"Every field in a Gravity Forms form has a numeric ID assigned in the form builder. In the gform_after_submission hook, field values are stored in the $entry array under their field ID as a string key: $entry['1'] for field 1, $entry['2'] for field 2, and so on. Use the rgar() helper to safely read these values: rgar($entry, '1'). Checkbox sub-fields use dot notation: rgar($entry, '6.1')."}},{"@type":"Question","name":"What happens if my endpoint is down when a Gravity Forms submission fires?","acceptedAnswer":{"@type":"Answer","text":"With a bare wp_remote_post() call, the data is lost silently — there's no retry and no log entry. With a queue-based system, the failed delivery is stored and retried automatically with exponential backoff (1 min, 2 min, 4 min, 8 min — 5 attempts). You can also replay any failed submission manually from the WordPress admin."}},{"@type":"Question","name":"Can I retry failed Gravity Forms webhook submissions?","acceptedAnswer":{"@type":"Answer","text":"Yes, if you use a plugin with retry support. Webhook Actions by Flow Systems retries failed deliveries automatically and also exposes a REST API endpoint (POST /wp-json/fswa/v1/logs/{id}/retry) that lets you replay any past submission — including successful ones."}}]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Examples","item":"https://wpwebhooks.org/examples/"},{"@type":"ListItem","position":3,"name":"Gravity Forms Webhook Plugin: Build It with AI","item":"https://wpwebhooks.org/examples/gravity-forms-webhooks/"}]} {"@context":"https://schema.org","@type":"HowTo","name":"How to Set Up a Gravity Forms Webhook with Build with AI","description":"Set up a reliable Gravity Forms webhook — without the Elite Webhooks Add-On — by describing the integration to the Build with AI agent in the Webhook Actions plugin.","step":[{"@type":"HowToStep","name":"Open Build with AI","text":"Install the free Webhook Actions plugin. The Webhook Actions admin menu opens directly on the Build with AI screen."},{"@type":"HowToStep","name":"Describe the integration","text":"Type: When a Gravity Forms form is submitted, send the entry as JSON to my webhook — and paste your endpoint URL."},{"@type":"HowToStep","name":"Review the plan","text":"The agent discovers the gform_after_submission hook on your site and proposes an ordered plan — trigger, endpoint, field mapping — which you can edit before it runs."},{"@type":"HowToStep","name":"Let it build","text":"The agent creates the webhook (disabled by default), maps numeric field IDs to readable keys from the captured payload, and probes the endpoint."},{"@type":"HowToStep","name":"Enable and test","text":"Confirm the enable step, submit a test entry, and check the Event Log for the delivery status. Failed deliveries retry automatically and can be replayed."}]} {"@context":"https://schema.org","@graph":[{"@type":"VideoObject","@id":"https://wpwebhooks.org/examples/gravity-forms-webhooks/#video-submitted","name":"Gravity Forms Webhooks in Action","description":"Gravity Forms webhooks in action — form submission, Event Log delivery status, and raw payload in log details","contentUrl":"https://wpwebhooks.org/examples/gravity-forms-webhooks/gform_submitted.mp4","thumbnailUrl":"https://wpwebhooks.org/videos/thumbnails/gform_submitted.webp","uploadDate":"2026-04-07T00:00:00+00:00","duration":"PT20S"},{"@type":"VideoObject","@id":"https://wpwebhooks.org/examples/gravity-forms-webhooks/#video-payload-mapping","name":"Gravity Forms Webhook Payload Mapping","description":"Gravity Forms webhooks payload mapping — selecting and renaming fields via the plugin UI without writing code","contentUrl":"https://wpwebhooks.org/videos/payload_mapping_via_UI.mp4","thumbnailUrl":"https://wpwebhooks.org/videos/thumbnails/payload_mapping_via_UI.webp","uploadDate":"2026-04-07T00:00:00+00:00","duration":"PT58S"}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/gravity-forms-n8n-flow.png","caption":"FIG 01 — Gravity Forms → Webhook Actions → n8n","description":"A Gravity Forms submission fires gform_after_submission. Webhook Actions POSTs a JSON payload with a bearer token to an n8n Webhook node, which verifies the token, branches on form_id, and maps fields; failures retry with exponential backoff.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "HubSpot WooCommerce Integration: Create & Update Deals" description: "Build a HubSpot WooCommerce integration that creates a deal on order placed and moves it to Closed Won on completion — REST API, Code Glue, 2026-03 endpoint." url: "https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/" date: "2026-05-15" --- [WP Webhooks](https://wpwebhooks.org/) / [Examples](https://wpwebhooks.org/examples/)/ [HubSpot × WooCommerce](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) / Create & Update Deals # HubSpot WooCommerce Integration: Create & Update Deals Two webhooks, one cohesive flow. Create a HubSpot deal the instant a WooCommerce order is placed, then flip it to _Closed Won_ when the order completes — using the 2026-03 CRM API, Code Glue, and zero Zapier. **~12 min read** May 15, 2026 Part **1** of 5 Filter OK Pro hubspotwoocommerce TL;DR — What you'll build - **Webhook 1 — Create Deal:** fires on `woocommerce_checkout_order_created`, POSTs order data to HubSpot, gets back a deal ID - **Post-dispatch Code Glue:** stores the returned deal ID in WC order meta as `_hubspot_deal_id` - **Webhook 2 — Update Deal Stage:** fires on `woocommerce_order_status_changed`, PATCHes the deal to _Closed Won_ when the order completes - Full delivery log, retries, and replay — no Zapier, no extra SaaS / Prerequisites ## What You Need **Before Starting** ⚡ This integration uses [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/) — a free WordPress webhook plugin. The deal-create half runs on the free plugin alone. The deal-stage update half uses the Pro plugin's **Code Glue** feature for storing the returned deal ID and injecting it into the next webhook's URL. 1. 1 **WordPress + WooCommerce with at least one published product** Any WooCommerce-compatible theme. You need to be able to place a real test order through the storefront or REST API. 2. 2 **Webhook Actions (free) — v1.12.0 or later** Search for **FlowSystems** in **Plugins → Add Plugin**. Install and activate. 3. 3 **Webhook Actions Pro Pro — v1.1.0 or later** Required for Code Glue (PHP snippets that run pre/post dispatch) and `{{ field.path }}` URL template syntax. 4. 4 **HubSpot Service Key or Private App access token** In HubSpot: **Settings → Integrations → Service Keys → Create service key**. Grant scopes `crm.objects.deals.read` and `crm.objects.deals.write`. Copy the token — starts with `pat-...`. 5. 5 **Webhook Actions API token with `full` scope** In **WP Admin → Webhook Actions → API Tokens → Add Token**. Select scope `full`. Copy the token — shown only once. set shell variables ``` export SITE="https://your-wordpress-site.com" export TOKEN="fswa_full_..." # Webhook Actions API token (full scope) export HS_PAT="pat-eu1-xxxxxxxx..." # HubSpot Private App token ``` / Architecture ## Two Webhooks, **One Shared State** A clean HubSpot WooCommerce integration needs two webhooks that share a single piece of state: the deal ID returned when the first webhook creates the deal. Without that shared state, the second webhook has nothing to PATCH against. The plugin solves this with two features working together: pre-dispatch Code Glue for payload shaping and URL template injection, and post-dispatch Code Glue for persisting the returned deal ID. flow diagram ``` # 1. ORDER CREATED WooCommerce order placed └── fires woocommerce_checkout_order_created └── Webhook #1 POST https://api.hubapi.com/crm/objects/2026-03/deals └── HubSpot responds 201 + {"id": "502475571425", ...} └── post-glue: update_post_meta($order_id, "_hubspot_deal_id", "502475571425") # 2. ORDER COMPLETED (minutes, hours, or days later) Order status changed → "completed" └── fires woocommerce_order_status_changed └── pre-glue: read _hubspot_deal_id from order meta → inject as _hs_deal_id └── URL template {{ _hs_deal_id }} resolves → /crm/objects/2026-03/deals/502475571425 └── Webhook #2 PATCH with {"properties": {"dealstage": "closedwon"}} └── HubSpot responds 200 — deal moved to Closed Won ``` **Hard rule for Code Glue.** Pre-dispatch glue is for _payload shaping only_. Never do side effects there — no `update_post_meta`, no outbound HTTP, no DB writes. Pre-glue can run multiple times (dispatch, retry, replay, preview). Reads like `get_post_meta` are fine. All write side effects go in post-dispatch glue. FIG 01 — Deal create → Closed Won / Step 1 ## Create the **"Deal Create"** Webhook The first webhook fires on `woocommerce_checkout_order_created`. If your store uses the block-based checkout, add `woocommerce_store_api_checkout_order_processed` to the triggers array — both fire with the same payload shape. We POST to HubSpot's new dated CRM endpoint: `https://api.hubapi.com/crm/objects/2026-03/deals`. The legacy `/crm/v3/objects/deals` path still works but HubSpot is migrating to dated paths — any new integration should start there. First, store the HubSpot PAT once in the plugin's **Credentials Vault** — webhooks then reference it by id via `auth_credential_id`, which takes precedence over the legacy plaintext `auth_header`. The secret is encrypted at rest and write-only over the API: it never shows up in webhook configs, logs, or agent responses. one-time — store the PAT in the Credentials Vault ``` CRED_ID=$(curl -sk -X POST "$SITE/wp-json/fswa/v1/credentials" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "HubSpot PAT", "type": "bearer", "secret": "'"$HS_PAT"'"}' | jq -r '.id') ``` step 1.1 — create the webhook via REST ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Create Deal", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/deals", "http_method": "POST", "auth_credential_id": '"$CRED_ID"', "is_enabled": true, "is_synchronous": true, "retry_limit": 1, "triggers": [ "woocommerce_checkout_order_created", "woocommerce_store_api_checkout_order_processed" ] }' ``` response — 201 Created ``` { "id": "29", "webhook_uuid": "a8f1d3...", "name": "HS - Create Deal", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/deals", "http_method": "POST", "is_synchronous": true, "triggers": [ "woocommerce_checkout_order_created", "woocommerce_store_api_checkout_order_processed" ] } ``` Webhook **ID 29** created. `is_synchronous: true` matters here — the next webhook depends on the deal ID stored by this one's post-glue, and async dispatch would race the order-status-changed trigger. Why two triggers? You need both. WooCommerce has two checkout types and each fires its own action: `woocommerce_checkout_order_created` (classic shortcode — still common on customised storefronts and B2B portals) and `woocommerce_store_api_checkout_order_processed` (block-based checkout — default in WooCommerce 8+ block themes). Each order fires exactly one of them. Registering on both makes the webhook source-agnostic. **Why not `woocommerce_new_order`?** It fires before line items and totals are populated — pre-glue would see an empty order. **Two triggers means double the setup.** Conditions and Code Glue snippet bindings are per-(webhook, trigger). When you attach snippets in Step 2.3, you need to do it once per trigger — that's why the curl examples use a `for TRIGGER in …; do …; done` loop. Skip the loop and only one checkout type gets the glue. Webhook Actions admin → Webhooks list with the new _HS - Create Deal_ webhook pointing at `/crm/objects/2026-03/deals`. / Step 2 Filter OK fswa\_webhook\_payloadfswa\_glue\_post\_dispatch Pro ## Map WC Order → Deal, **Capture the Deal ID** The raw WooCommerce trigger payload isn't shaped like a HubSpot deal — we need to map fields. And after HubSpot returns the new deal's ID, we need to store it on the order so the next webhook can find it. Two snippets: pre-glue for mapping, post-glue for persistence. ### 2.1 — Pre-dispatch: map order → deal properties create the pre-dispatch snippet ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/pro/snippets" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Map WC Order to Deal", "code": "" }' ``` snippet code — PHP ``` // Pre-dispatch glue — payload shaping only. NO side effects. // Input: $payload, $args, $webhook, $trigger $order = $payload["args"][0] ?? []; $orderId = $order["id"] ?? null; if (!$orderId) return $payload; $billing = $order["billing"] ?? []; $name = trim(($billing["first_name"] ?? "") . " " . ($billing["last_name"] ?? "")); return [ "_wc_order_id" => (string) $orderId, "properties" => [ "dealname" => "WC Order #$orderId — $name", "amount" => (string) ($order["total"] ?? 0), "dealstage" => "appointmentscheduled", "pipeline" => "default", "closedate" => date("c", strtotime("+30 days")), ], ]; ``` **About `_wc_order_id`:** we add it at the root of the returned array, alongside `properties`. HubSpot ignores unknown root-level keys, but the same payload object is what post-glue receives — so we can read the order ID back without re-parsing args. A clean way to carry context across pre and post. ### 2.2 — Post-dispatch: store the returned deal ID Post-dispatch glue is where side effects belong. It fires _after_ the HTTP response comes back, with the response body and status code available. We parse the JSON, pull the `id`, and write it to WC order meta as `_hubspot_deal_id`. create the post-dispatch snippet ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/pro/snippets" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Store Deal ID in WC Meta", "code": "" }' ``` snippet code — PHP ``` // Post-dispatch glue — runs AFTER the HTTP response. Side effects belong here. // Available: $payload, $responseCode, $responseBody, $webhook, $trigger $data = json_decode($responseBody, true); $orderId = (int) ($payload["_wc_order_id"] ?? 0); if ((int)$responseCode === 201 && !empty($data["id"]) && $orderId) { $order = wc_get_order($orderId); if ($order) { // HPOS-safe: use $order->update_meta_data, not update_post_meta $order->update_meta_data("_hubspot_deal_id", $data["id"]); $order->save(); } } ``` **HPOS heads-up.** WooCommerce 8+ stores order meta in `wp_wc_orders_meta`, not `wp_postmeta`. Using `update_post_meta($orderId, ...)` writes to the legacy table and is invisible to `$order->get_meta()`. Always use `wc_get_order()->update_meta_data()` + `$order->save()` for WC orders. ### 2.3 — Attach both snippets to the webhook + trigger Replace `$PRE_ID` and `$POST_ID` with the snippet IDs returned above. Attach to both checkout triggers. attach pre + post glue to both triggers ``` for TRIGGER in woocommerce_checkout_order_created woocommerce_store_api_checkout_order_processed; do curl -sk -X POST \ "$SITE/wp-json/fswa/v1/pro/trigger-snippets/29/trigger/$TRIGGER" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"pre_snippet_id": '"$PRE_ID"', "pre_enabled": true, "post_snippet_id": '"$POST_ID"', "post_enabled": true}' done ``` Webhook Actions admin → Code Glue with both snippets attached (pre-dispatch mapping + post-dispatch persistence). / Step 3 Filter OK fswa\_webhook\_urlfswa\_webhook\_payload Pro ## Create the **"Deal Stage Update"** Webhook The second webhook fires on `woocommerce_order_status_changed`. The URL template feature embeds the HubSpot deal ID directly in the endpoint URL — Pro's URL filter expands `{{ _hs_deal_id }}` at dispatch time, after pre-glue has populated it from order meta. step 3.1 — create the update webhook ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Update Deal Stage", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/deals/{{ _hs_deal_id }}", "http_method": "PATCH", "auth_credential_id": '"$CRED_ID"', "is_enabled": true, "is_synchronous": true, "retry_limit": 1, "triggers": ["woocommerce_order_status_changed"] }' ``` response — webhook ID 30 ``` { "id": "30", "name": "HS - Update Deal Stage", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/deals/{{ _hs_deal_id }}", "http_method": "PATCH", "triggers": ["woocommerce_order_status_changed"] } ``` **If you already set up this integration before May 2026** and your existing webhook 30 points at the v3 endpoint, update it with a single PATCH — no other changes needed: migrate legacy v3 endpoint → 2026-03 ``` curl -sk -X PATCH "$SITE/wp-json/fswa/v1/webhooks/30" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/deals/{{ _hs_deal_id }}"}' ``` Webhook Actions admin → webhook 30 with the 2026-03 endpoint and dynamic `{{ _hs_deal_id }}` URL template. ### 3.2 — Condition: only fire when order moves to "completed" `woocommerce_order_status_changed` fires on every status transition. We only want to flip the deal when the new status is `completed`: add condition: new\_status equals completed ``` curl -sk -X POST \ "$SITE/wp-json/fswa/v1/schemas/webhook/30/trigger/woocommerce_order_status_changed" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [{ "field": "args.2", "operator": "equals", "value": "completed", "cast": "string" }] }, "conditions_evaluate_on": "original" }' ``` ### 3.3 — Pre-glue: inject the deal ID for the URL template Pre-glue reads `_hubspot_deal_id` from order meta and returns it as `_hs_deal_id` for the URL filter to consume, plus the actual PATCH body in `properties`. create the update pre-dispatch snippet ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/pro/snippets" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Inject Deal ID for URL + Stage Update", "code": "" }' ``` snippet code — PHP ``` // Pre-dispatch glue — payload shaping only. // Reads _hubspot_deal_id stored by Step 2 post-glue (local read, allowed). $orderId = (int) ($payload["args"][0] ?? 0); $dealId = $orderId ? (string) get_post_meta($orderId, "_hubspot_deal_id", true) : ""; return [ "_hs_deal_id" => $dealId, // consumed by URL template, not sent to HubSpot "properties" => [ "dealstage" => "closedwon", ], ]; ``` **Why root-level keys like `_hs_deal_id` are safe.** HubSpot's deals PATCH endpoint reads `properties` and `associations` at the body root and silently ignores everything else. Root-level keys act as a private channel for Webhook Actions features without polluting the HubSpot payload. attach pre-glue (no post-glue needed for this webhook) ``` curl -sk -X POST \ "$SITE/wp-json/fswa/v1/pro/trigger-snippets/30/trigger/woocommerce_order_status_changed" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"pre_snippet_id": '"$UPDATE_PRE_ID"', "pre_enabled": true, "post_enabled": false}' ``` / Step 4 ## End-to-End Test — **Real Order, Real Deal** Place a real test order through your WooCommerce storefront. The instant the order is created, the first webhook fires and creates a HubSpot deal. Post-glue writes `_hubspot_deal_id` to the order, and HubSpot now shows a deal in _Appointment Scheduled_. HubSpot → CRM → Deals — the WooCommerce order has just created a deal in _Appointment Scheduled_. Now mark the order **Completed** in **WP Admin → WooCommerce → Orders**. The second webhook fires immediately — pre-glue reads `_hubspot_deal_id` from order meta, the URL template expands to `/crm/objects/2026-03/deals/502475571425`, and HubSpot PATCHes the deal to `closedwon`. HubSpot → CRM → Deals — the same deal, now in _Closed Won_, after the order was marked completed. Webhook Actions admin → Logs — both deliveries succeed: 201 from _Create Deal_, 200 from _Update Deal Stage_. **That's the full integration.** Every WooCommerce order now creates a HubSpot deal automatically, and every completed order flips that deal to Closed Won. Failures are visible in the log, can be replayed with one click, and the per-webhook delivery view shows exactly what payload was sent and what HubSpot returned. / Common Pitfalls ## Things **That Bite** **Wrong endpoint version.** Older HubSpot docs use `/crm/v3/objects/deals`. That still works, but mixed-version setups confuse maintainers. Standardise on `/crm/objects/2026-03/deals` for any new webhook. **Pipeline / dealstage internal IDs.** HubSpot's UI shows "Closed Won" — the API needs the internal ID. For the default pipeline: `appointmentscheduled`, `closedwon`, `closedlost`. For custom pipelines, fetch them via `GET /crm/v3/pipelines/deals`. **Side effects in pre-glue.** The hard rule: pre-dispatch glue must be pure. If you put `update_post_meta` in pre-glue, every retry doubles the meta write. Move it to post-glue. **Async dispatch racing the next trigger.** If `is_synchronous` is `false` on webhook 29, post-glue may not have finished writing the deal ID before the status-changed trigger fires. Keep webhook 29 synchronous. **Missing condition on webhook 30.** Without the `args.2 = completed` condition, webhook 30 PATCHes on every status change — hitting HubSpot three times per order. The condition keeps it single-fire. / HubSpot × WooCommerce Series ← Previous (Series start) [All 5 parts Series Index](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) [Next → Part 2 — Sync Products to HubSpot](https://wpwebhooks.org/examples/hubspot-product-sync-woocommerce/) /Notes → [WordPress Webhook REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/) — full reference for webhooks, schemas, conditions, and logs → [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/) — plugin overview → [HubSpot Deals API — create-deal reference (2026-03)](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/deals/create-deal) → [All WordPress webhook automation examples](https://wpwebhooks.org/examples/) /FAQ ## Common questions always ask. Don't see yours? Open an issue on GitHub or check the full reference in the API docs. Does this HubSpot WordPress plugin work without writing code? + The free plugin sets up the deal-create webhook with zero code — REST API or admin UI. The deal-stage update half needs Code Glue: two short PHP snippets pasted into the plugin admin, not custom theme code. If you prefer pure no-code, the series hub covers examples that are pure REST. How is this different from the official HubSpot WooCommerce integration? + HubSpot's official integration pushes a fixed schema you don't control. This setup lets you decide exactly which order fields become which deal properties, on which trigger, with which conditions — plus a full delivery log with replay. Less out-of-the-box, more precision. Why use the new 2026-03 HubSpot endpoint instead of /crm/v3/objects/deals? + HubSpot's 2026-03 dated API endpoint is the current versioned path. The /crm/v3/objects/deals path still works but HubSpot is migrating examples to the dated paths. If your existing webhook still uses v3, update endpoint\_url via a single PATCH — auth header, condition, and pre-glue stay identical. What happens if the Create Deal webhook fails — do we still try to update? + No. The post-dispatch Code Glue only writes \_hubspot\_deal\_id to WC order meta on a 201 response. The Update Deal Stage webhook depends on that meta via the URL template — if empty, the URL is malformed and HubSpot returns 400, visible in the log. Add an is\_not\_empty condition on webhook 30 to skip cleanly. Can I do this on the free plugin without Code Glue? + The deal-create half — yes, fully free. The deal-stage update needs the HubSpot deal ID from the create response. Without Code Glue you can hook fswa\_webhook\_url and fswa\_glue\_post\_dispatch in your own mu-plugin. The Pro snippet-based flow is recommended because snippets are portable, editable in admin, and visible to non-developers. Do I need to install this on every WordPress site that runs WooCommerce? + Yes — Webhook Actions is a per-site plugin. If you run 5 stores, each gets its own install and API tokens. The Pro license covers unlimited sites, and webhooks/snippets can be exported and re-imported via JSON export/import in the admin. /More examples ## Related integrations. Series Hub HubSpot × WooCommerce — All 5 Parts The complete integration series — deals, products, line items, customers, and the full graph. [Read →](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) Part 2 of 5 HubSpot WooCommerce Product Sync Push WC products to HubSpot on publish (POST), keep them in sync on update (PATCH). [Read →](https://wpwebhooks.org/examples/hubspot-product-sync-woocommerce/) Tutorial Ask Claude Code to Set Up a WooCommerce Webhook A real Claude Code session — REST API discovery, payload-path correction, and a live fix before shipping. [Read →](https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"HubSpot WooCommerce Integration: Create & Update Deals from Orders","description":"Build a HubSpot WooCommerce integration that creates a deal on order placed and moves it to Closed Won on completion — REST API, Code Glue, 2026-03 endpoint.","datePublished":"2026-05-15","dateModified":"2026-05-15","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org","sameAs":["https://flowsystems.pl"]},"url":"https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/og_image.jpg"},"keywords":["hubspot woocommerce integration","hubspot woocommerce","woocommerce hubspot integration","hubspot wordpress plugin","hubspot crm wordpress","hubspot deal woocommerce","woocommerce order to hubspot"],"isPartOf":{"@type":"CreativeWorkSeries","name":"HubSpot × WooCommerce Integration Series","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"}} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does this HubSpot WordPress plugin work without writing code?","acceptedAnswer":{"@type":"Answer","text":"The free plugin sets up the deal-create webhook with zero code — REST API or admin UI. The deal-stage update half needs Code Glue: two short PHP snippets pasted into the plugin admin, not custom theme code. If you prefer pure no-code, the series hub covers examples that are pure REST."}},{"@type":"Question","name":"How is this different from the official HubSpot WooCommerce integration?","acceptedAnswer":{"@type":"Answer","text":"HubSpot's official integration pushes a fixed schema you don't control. This setup lets you decide exactly which order fields become which deal properties, on which trigger, with which conditions — plus a full delivery log with replay. Less out-of-the-box, more precision."}},{"@type":"Question","name":"Why use the new 2026-03 HubSpot endpoint instead of /crm/v3/objects/deals?","acceptedAnswer":{"@type":"Answer","text":"HubSpot's 2026-03 dated API endpoint is the current versioned path. The /crm/v3/objects/deals path still works but HubSpot is migrating examples to the dated paths. If your existing webhook still uses v3, update endpoint_url via a single PATCH — auth header, condition, and pre-glue stay identical."}},{"@type":"Question","name":"What happens if the Create Deal webhook fails — do we still try to update?","acceptedAnswer":{"@type":"Answer","text":"No. The post-dispatch Code Glue only writes _hubspot_deal_id to WC order meta on a 201 response. The Update Deal Stage webhook depends on that meta via the URL template — if empty, the URL is malformed and HubSpot returns 400, visible in the log. Add an is_not_empty condition on webhook 30 to skip cleanly."}},{"@type":"Question","name":"Can I do this on the free plugin without Code Glue?","acceptedAnswer":{"@type":"Answer","text":"The deal-create half — yes, fully free. The deal-stage update needs the HubSpot deal ID from the create response. Without Code Glue you can hook fswa_webhook_url and fswa_glue_post_dispatch in your own mu-plugin. The Pro snippet-based flow is recommended because snippets are portable, editable in admin, and visible to non-developers."}},{"@type":"Question","name":"Do I need to install this on every WordPress site that runs WooCommerce?","acceptedAnswer":{"@type":"Answer","text":"Yes — Webhook Actions is a per-site plugin. If you run 5 stores, each gets its own install and API tokens. The Pro license covers unlimited sites, and webhooks/snippets can be exported and re-imported via JSON export/import in the admin."}}]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Examples","item":"https://wpwebhooks.org/examples/"},{"@type":"ListItem","position":3,"name":"HubSpot × WooCommerce","item":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"},{"@type":"ListItem","position":4,"name":"Create & Update Deals","item":"https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/"}]} {"@context":"https://schema.org","@type":"HowTo","name":"How to Build a HubSpot WooCommerce Integration That Creates and Updates Deals","description":"Set up two webhooks — one creates a HubSpot deal on WooCommerce order creation, the other moves the deal to Closed Won when the order is completed — using the Webhook Actions plugin and HubSpot's 2026-03 CRM API.","step":[{"@type":"HowToStep","name":"Prepare HubSpot and Webhook Actions credentials","text":"Create a HubSpot Private App with crm.objects.deals.write scope. Create an Webhook Actions API token with full scope in the WordPress admin."},{"@type":"HowToStep","name":"Create the Deal Create webhook","text":"POST to /wp-json/fswa/v1/webhooks with endpoint https://api.hubapi.com/crm/objects/2026-03/deals on the woocommerce_checkout_order_created trigger."},{"@type":"HowToStep","name":"Add Pro Code Glue to capture the deal ID","text":"Create a pre-dispatch snippet that maps WC order to HubSpot deal properties, and a post-dispatch snippet that stores the returned deal ID in WC order meta."},{"@type":"HowToStep","name":"Create the Deal Stage Update webhook","text":"POST to /wp-json/fswa/v1/webhooks with endpoint https://api.hubapi.com/crm/objects/2026-03/deals/{{ _hs_deal_id }} on the woocommerce_order_status_changed trigger, with PATCH method and a condition on status=completed."},{"@type":"HowToStep","name":"End-to-end test","text":"Place a real order through WooCommerce, watch the Webhook Actions log show 201 from Create Deal, then mark the order Completed and watch the second webhook return 200 from the 2026-03 deals endpoint."}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/hubspot-deal-lifecycle.png","caption":"FIG 01 — Deal create → Closed Won","description":"On checkout, the first webhook creates a HubSpot deal and post-dispatch glue stores its id on the order. When the order status changes to completed, the second webhook PATCHes the deal stage to Closed Won.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "HubSpot WooCommerce Product Sync: Push & Update Products" description: "Mirror your WooCommerce catalog into HubSpot — push products on publish, PATCH on update — with the 2026-03 HubSpot CRM API and Code Glue. Free + Pro paths." url: "https://wpwebhooks.org/examples/hubspot-product-sync-woocommerce/" date: "2026-05-15" --- [WP Webhooks](https://wpwebhooks.org/) / [Examples](https://wpwebhooks.org/examples/)/ [HubSpot × WooCommerce](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) / Product Sync # HubSpot WooCommerce Product Sync: Push & Update Products from WC Mirror your WooCommerce catalog into HubSpot. Publish a WC product → HubSpot product created. Edit it → HubSpot PATCHed. Built on the new 2026-03 products endpoint, with a free one-way path and a Pro stateful path that prepares the ground for line-item associations in Part 3. **~10 min read** May 15, 2026 Part **2** of 5 Filter OK Pro hubspotwoocommerceproduct-synccatalog TL;DR — What you'll build - **Webhook 1 — Product Create:** fires on `woocommerce_new_product` + `woocommerce_update_product`, POSTs WC product fields to `https://api.hubapi.com/crm/objects/2026-03/products` - **Post-dispatch Code Glue:** stores returned HubSpot product ID in WC product meta as `_hubspot_product_id` — Part 3 reads this - **Webhook 2 — Product Update:** fires on `woocommerce_update_product`, PATCHes `/crm/objects/2026-03/products/{{ _hs_product_id }}` only when an ID already exists - **Condition split keeps them orthogonal:** create has `_hs_existing_id is_empty`; update has `_hs_product_id is_not_empty` / Prerequisites ## What You Need **Before Starting** ⚡ This hubspot woocommerce product sync uses [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/). The create webhook is a plain one-way push with no Code Glue. The update webhook uses Code Glue and dynamic URL templates, and is the prerequisite for line-item associations in [Part 3](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/). 1. 1 **WordPress + WooCommerce with at least one product to test** Triggers fire for any product type — simple, variable, virtual, downloadable. 2. 2 **Webhook Actions (free) v1.12.0+** Search for **FlowSystems** under **Plugins → Add Plugin**. 3. 3 **Webhook Actions Pro Pro v1.1.0+ — only for the update half** Required for Code Glue and `{{ field.path }}` URL templates. Skip if you're doing one-way push only. 4. 4 **HubSpot Private App token with product scopes** If you followed [Part 1](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/), edit your existing app and add `e-commerce` scope. Copy the new token. 5. 5 **Webhook Actions API token with `full` scope** Create at **WP Admin → Webhook Actions → API Tokens**. shell variables ``` export SITE="https://your-wordpress-site.com" export TOKEN="fswa_full_..." export HS_PAT="pat-eu1-xxxxxxxx..." ``` / Architecture ## Two Webhooks, **Same Product Object** WooCommerce fires `woocommerce_new_product` on creation and `woocommerce_update_product` on every subsequent edit. The publish-from-draft transition fires _update_, not _new_ — so we listen to both. HubSpot's 2026-03 products endpoint takes the same JSON shape as deals: a `properties` bag at the body root. The trick that keeps two webhooks orthogonal on the same trigger: opposite conditions. Webhook 32 fires only when no HubSpot ID is stored yet. Webhook 33 fires only when one is. Each WC update event matches exactly one. flow diagram ``` # 1. PRODUCT PUBLISHED (first save with status=publish) WooCommerce product saved └── fires woocommerce_new_product | woocommerce_update_product └── Webhook #1 POST /crm/objects/2026-03/products └── HubSpot 201 + {"id": "30258041729"} └── post-glue: update_post_meta($product_id, "_hubspot_product_id", "30258041729") # 2. PRODUCT EDITED (any subsequent change) Product price/name/desc changed └── fires woocommerce_update_product └── pre-glue: read _hubspot_product_id → inject as _hs_product_id └── URL template resolves → /crm/objects/2026-03/products/30258041729 └── Webhook #2 PATCH with updated {"properties": ...} └── HubSpot 200 ``` **Same hard rule as Part 1.** Pre-dispatch glue is payload shaping only — no `update_post_meta`, no outbound HTTP. Pre-glue runs on every retry and replay, so any side effect there duplicates. Writes go to post-glue. [Read why →](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) FIG 01 — Product create vs update split / Step 1 ## Create the **"Product Create"** Webhook Register a single webhook on two triggers so the first publish and any new-product REST call both fire it. First, store the HubSpot PAT once in the plugin's **Credentials Vault** — webhooks then reference it by id via `auth_credential_id`, which takes precedence over the legacy plaintext `auth_header`. The secret is encrypted at rest and write-only over the API: it never shows up in webhook configs, logs, or agent responses. one-time — store the PAT in the Credentials Vault ``` CRED_ID=$(curl -sk -X POST "$SITE/wp-json/fswa/v1/credentials" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "HubSpot PAT", "type": "bearer", "secret": "'"$HS_PAT"'"}' | jq -r '.id') ``` step 1.1 — create the webhook ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Create Product", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/products", "http_method": "POST", "auth_credential_id": '"$CRED_ID"', "is_enabled": true, "is_synchronous": true, "retry_limit": 1, "triggers": ["woocommerce_new_product", "woocommerce_update_product"] }' ``` response — webhook ID 32 ``` { "id": "32", "name": "HS - Create Product", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/products", "http_method": "POST" } ``` ### 1.2 — Condition: only fire if the product has no HubSpot ID yet Repeated updates must not create new HubSpot products. The condition checks a pre-glue-injected field (`_hs_existing_id`) and skips dispatch when it's not empty. add condition ``` for TRIGGER in woocommerce_new_product woocommerce_update_product; do curl -sk -X POST \ "$SITE/wp-json/fswa/v1/schemas/webhook/32/trigger/$TRIGGER" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [{"field": "_hs_existing_id", "operator": "is_empty"}] }, "conditions_evaluate_on": "transformed" }' done ``` **Common bug: `evaluate_on` placement.** Webhook Actions reads `conditions_evaluate_on` as a _top-level_ field on the schema, not nested inside `conditions`. Putting `"evaluate_on": "transformed"` inside the conditions object is silently dropped and the condition then evaluates against the original (pre-glue) payload — meaning fields like `_hs_product_id` that only exist after pre-glue won't match. Verified live 2026-05-15 on webhook-actions.local. / Step 2 Pro ## Map WC Product → HubSpot Product, **Capture the ID** ### 2.1 — Pre-dispatch: map WC product to HubSpot properties snippet code — PHP ``` // Pre-dispatch glue — payload shaping only. NO side effects. $product = $payload["args"][0] ?? []; $productId = is_object($product) ? $product->get_id() : ((int)($product["id"] ?? 0)); if (!$productId) return $payload; $wc = wc_get_product($productId); if (!$wc) return $payload; $existingId = (string) get_post_meta($productId, "_hubspot_product_id", true); return [ "_wc_product_id" => (string) $productId, "_hs_existing_id" => $existingId, "properties" => [ "name" => $wc->get_name(), "description" => wp_strip_all_tags($wc->get_short_description() ?: $wc->get_description()), "price" => (string) $wc->get_regular_price(), "hs_sku" => $wc->get_sku() ?: "wc-{$productId}", "hs_url" => get_permalink($productId), ], ]; ``` **Variations.** For variable products, branch on `$wc->is_type("variable")` and either iterate `$wc->get_children()` (recommended for Part 3) or flatten variations into a single HubSpot product with attribute properties. ### 2.2 — Post-dispatch: store the returned HubSpot product ID snippet code — PHP ``` // Post-dispatch glue — side effects allowed. $data = json_decode($responseBody, true); $productId = (int) ($payload["_wc_product_id"] ?? 0); if ($responseCode === 201 && !empty($data["id"]) && $productId) { update_post_meta($productId, "_hubspot_product_id", $data["id"]); update_post_meta($productId, "_hubspot_product_synced_at", current_time("mysql")); } ``` ### 2.3 — Attach both snippets attach pre + post glue to both triggers ``` for TRIGGER in woocommerce_new_product woocommerce_update_product; do curl -sk -X POST \ "$SITE/wp-json/fswa/v1/pro/trigger-snippets/32/trigger/$TRIGGER" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d "{"pre_snippet_id": $PRE_ID, "pre_enabled": true, "post_snippet_id": $POST_ID, "post_enabled": true}" done ``` Webhook Actions admin → Code Glue Snippets list with Product Create pre + post snippets attached to webhook 32. / Step 3 Pro ## Create the **"Product Update"** Webhook Same trigger as Step 1, opposite condition. It fires only when the product _already_ has `_hubspot_product_id`, PATCHes the HubSpot product by ID, and refreshes the property bag. step 3.1 — create the update webhook ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Update Product", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/products/{{ _hs_product_id }}", "http_method": "PATCH", "auth_credential_id": '"$CRED_ID"', "is_enabled": true, "is_synchronous": false, "retry_limit": 2, "triggers": ["woocommerce_update_product"] }' ``` ### 3.2 — Pre-glue: inject ID from product meta snippet code — PHP ``` // Pre-dispatch glue — read product meta, shape PATCH body. $product = $payload["args"][0] ?? []; $productId = is_object($product) ? $product->get_id() : ((int)($product["id"] ?? 0)); $hsId = $productId ? (string) get_post_meta($productId, "_hubspot_product_id", true) : ""; if (!$hsId) return $payload; $wc = wc_get_product($productId); if (!$wc) return $payload; return [ "_hs_product_id" => $hsId, "properties" => [ "name" => $wc->get_name(), "description" => wp_strip_all_tags($wc->get_short_description() ?: $wc->get_description()), "price" => (string) $wc->get_regular_price(), "hs_sku" => $wc->get_sku() ?: "wc-{$productId}", ], ]; ``` ### 3.3 — Condition: only fire when there's an ID to PATCH against add condition ``` curl -sk -X POST \ "$SITE/wp-json/fswa/v1/schemas/webhook/33/trigger/woocommerce_update_product" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [{"field": "_hs_product_id", "operator": "is_not_empty"}] }, "conditions_evaluate_on": "transformed" }' ``` **The two-webhook trick.** Webhook 32 has `_hs_existing_id is_empty`. Webhook 33 has `_hs_product_id is_not_empty`. The same `woocommerce_update_product` event flows through both pipelines — the condition system picks exactly one based on whether the meta is already present. No coordination, no race. **Note:** webhook IDs depend on your install — substitute your own. / Step 4 ## End-to-End Test — **Publish, Edit, Verify** **WP Admin → Products → Add New.** Name, price, SKU (e.g. `WIDGET-001`). Click **Publish**. Within a second, the Webhook Actions log shows 201 from webhook 32, and HubSpot's **CRM → Products** shows the new entry. Open the same product, change the price, click **Update**. The Webhook Actions log shows webhook 32 _skipped_ (matched `_hs_existing_id is_not_empty`) and webhook 33 returning 200 against `/crm/objects/2026-03/products/{ID}`. Refresh HubSpot — price reflects the new value. HubSpot → CRM → Products with the newly synced WooCommerce widget visible. Webhook Actions admin → Logs showing webhook 32 (201) on first publish, webhook 32 (skipped) + webhook 33 (200) on the second edit. / Backfill ## One-Off Sync for **Existing Products** If you already have a catalog, fire the create webhook once per existing product. The simplest path is WP-CLI: wp-cli backfill loop ``` wp eval ' $products = wc_get_products(["limit" => -1, "status" => "publish", "return" => "ids"]); foreach ($products as $pid) { if (get_post_meta($pid, "_hubspot_product_id", true)) { continue; } do_action("woocommerce_new_product", $pid); echo "fired for product $pid "; }' ``` **Why `do_action` instead of curl loops?** Firing the WC action runs the full Webhook Actions pipeline (conditions, pre-glue, dispatch, post-glue) so post-glue still persists `_hubspot_product_id` on every success. Curl-looping the HubSpot API directly skips that and loses the link back to WC meta. / Common Pitfalls ## Things **That Bite** **Same trigger, two webhooks, no condition.** Forget the `_hs_existing_id is_empty` / `is_not_empty` split and every product update creates a duplicate. The condition keeps the two orthogonal. **SKU collisions.** HubSpot stores `hs_sku` as free-text. Empty WC SKUs would all share an identity in HubSpot reporting — the fallback `wc-{$productId}` prevents that. **Variations vs simple products.** Pick one model and document it: per-variation HubSpot products (recommended for Part 3) or flattened parent with attribute properties. **HubSpot scope 403.** Response `{"status":"error","message":"This app hasn't been granted required scopes"}` means the Private App is missing `e-commerce`. Edit the app, add it, refresh `$HS_PAT`. **Pre-glue side effects.** Re-stating: no `update_post_meta` in pre-glue. Tag the product as in-flight from a post-glue branch instead. / HubSpot × WooCommerce Series [← Previous Part 1 — Create & Update Deals](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) [All 5 parts Series Index](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) [Next → Part 3 — Order Line Items](https://wpwebhooks.org/examples/woocommerce-order-line-items-hubspot/) /Notes → [WordPress Webhook REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/) — full reference for webhooks, schemas, conditions, and logs → [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/) — plugin overview → [HubSpot Products — create-product reference (2026-03)](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/products/create-product) → [HubSpot Products — update-product reference (2026-03)](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/products/update-product) → [All WordPress webhook automation examples](https://wpwebhooks.org/examples/) /FAQ ## Common questions always ask. Don't see yours? Open an issue on GitHub or check the full reference in the API docs. Why sync WooCommerce products to HubSpot at all? + HubSpot's line\_items API requires an hs\_product\_id to associate purchased items with a deal. Without product sync, deals have no SKU-level detail in HubSpot reporting. Do I need Pro for this hubspot woocommerce product sync? + No — one-way push runs on the free plugin. Pro is required if you want post-dispatch persistence of the returned hs\_product\_id (which Part 3 needs). What happens on product update? Does it create duplicates? + Not with the two-webhook condition split. Webhook 32 fires only when \_hs\_existing\_id is empty; webhook 33 fires only when \_hs\_product\_id is not empty. Exactly one matches per WC update event. Why not use HubSpot's e-commerce bridge for products? + The ecommerce-bridge endpoint is being deprecated in favor of the unified CRM objects API. The 2026-03 dated path is the supported way to push products. What about product variations? + Two options — push each variation as its own HubSpot product (recommended for Part 3), or collapse variations into a single HubSpot product with attribute properties. /More examples ## Related integrations. Part 1 Create & Update Deals from WooCommerce Orders Build the canonical hubspot woocommerce integration: deal on order placed, Closed Won on completion. [Read →](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) Part 3 (next) Order Line Items in HubSpot Iterate WC line\_items, POST each to HubSpot, associate to the deal — using the product IDs you just stored. [Read →](https://wpwebhooks.org/examples/woocommerce-order-line-items-hubspot/) Series Hub HubSpot × WooCommerce — All 5 Parts The complete integration series — deals, products, line items, customers, and the full graph. [Read →](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"HubSpot WooCommerce Product Sync: Push and Update Products on Publish","description":"Mirror your WooCommerce catalog into HubSpot — push products on publish, PATCH on update — with the 2026-03 HubSpot CRM API and Code Glue.","datePublished":"2026-05-15","dateModified":"2026-05-15","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org","sameAs":["https://flowsystems.pl"]},"url":"https://wpwebhooks.org/examples/hubspot-product-sync-woocommerce/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/og_image.jpg"},"keywords":["hubspot woocommerce","hubspot product sync","woocommerce product sync hubspot","hubspot wordpress plugin","woocommerce hubspot integration"],"isPartOf":{"@type":"CreativeWorkSeries","name":"HubSpot × WooCommerce Integration Series","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"}} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why sync WooCommerce products to HubSpot at all?","acceptedAnswer":{"@type":"Answer","text":"HubSpot's line_items API requires an hs_product_id to associate purchased items with a deal. Without product sync, deals have no SKU-level detail in HubSpot reporting."}},{"@type":"Question","name":"Do I need Pro for this hubspot woocommerce product sync?","acceptedAnswer":{"@type":"Answer","text":"No — one-way push runs on the free plugin. Pro is required if you want post-dispatch persistence of the returned hs_product_id (which Part 3 needs)."}},{"@type":"Question","name":"What happens on product update? Does it create duplicates?","acceptedAnswer":{"@type":"Answer","text":"Not with the two-webhook condition split. Webhook 32 fires only when _hs_existing_id is empty; webhook 33 fires only when _hs_product_id is not empty. Exactly one matches per WC update event."}},{"@type":"Question","name":"Why not use HubSpot's e-commerce bridge for products?","acceptedAnswer":{"@type":"Answer","text":"The ecommerce-bridge endpoint is being deprecated in favor of the unified CRM objects API. The 2026-03 dated path is the supported way to push products."}},{"@type":"Question","name":"What about product variations?","acceptedAnswer":{"@type":"Answer","text":"Two options — push each variation as its own HubSpot product (recommended for Part 3), or collapse variations into a single HubSpot product with attribute properties."}}]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Examples","item":"https://wpwebhooks.org/examples/"},{"@type":"ListItem","position":3,"name":"HubSpot × WooCommerce","item":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"},{"@type":"ListItem","position":4,"name":"Product Sync","item":"https://wpwebhooks.org/examples/hubspot-product-sync-woocommerce/"}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/hubspot-product-sync.png","caption":"FIG 01 — Product create vs update split","description":"A new product fires the create webhook that POSTs to HubSpot and stores the product id. A product update fires the update webhook only when a HubSpot product id already exists, PATCHing the record; otherwise it is skipped, so the two webhooks stay orthogonal.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "HubSpot WordPress Integration: Sync Contact on User Register" description: "Mirror every WordPress user registration into HubSpot CRM as a Contact — idempotent by email via the 2026-03 batch/upsert endpoint and Code Glue. Pre-populates user_meta so the Part 5 order chain hits the cheap PUT path on day one." url: "https://wpwebhooks.org/examples/add-customer-to-hubspot-on-woocommerce-order/" date: "2026-05-15" --- [WP Webhooks](https://wpwebhooks.org/) / [Examples](https://wpwebhooks.org/examples/)/ [HubSpot × WooCommerce](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) / Customer to Contact # HubSpot WordPress Integration: Sync Contact on User Register Mirror every new WordPress user into HubSpot CRM as a Contact — idempotent by email so the same person never duplicates. Built on the 2026-03 `contacts/batch/upsert` endpoint and stored to `user_meta._hubspot_contact_id` so Part 5's order-time chain hits the cheap PUT path instead of upserting again. **~10 min read** May 15, 2026 Part **4** of 5 Filter OK Pro hubspotcontactwoocommerce TL;DR — What you'll build - **Webhook 56 — Sync Contact on User Register:** fires on the WP `user_register` action, POSTs to `https://api.hubapi.com/crm/objects/2026-03/contacts/batch/upsert` - **Pre-glue:** reads `$payload["args"][0]` (user\_id), resolves email + name via `get_userdata()`. Skips re-fire if `user_meta._hubspot_contact_id` is already set. - **Post-glue:** reads `results[0].id`, writes as `_hubspot_contact_id` to `user_meta` - **No duplicates, no race condition.** The upsert is idempotent by email; the pre-glue guard skips when user\_meta is already populated. / Prerequisites ## What You Need **Before Starting** ⚡ This part stands alone — it works without any of the other parts in the series. It's also fully _optional_: Part 5 handles new-customer contact creation in-chain at order time. This part exists to pre-populate `user_meta._hubspot_contact_id` so the first order from a registered user is one HubSpot call instead of two. 1. 1 **WordPress (WooCommerce optional)** The `user_register` action fires on any WP user creation — the WC customer account flow, your registration form, manual `/wp-admin/user-new.php`, programmatic `wp_insert_user` calls, and so on. 2. 2 **Webhook Actions (free) v1.12.0+** Search for **FlowSystems** in plugin install. 3. 3 **HubSpot Private App with `crm.objects.contacts.write`** Add to your existing app from Parts 1-3 or create a new one. 4. 4 **Webhook Actions Pro Pro v1.1.0+ — for pre-glue idempotency + post-glue ID capture** Required for this article's snippet approach. Without Pro, implement the same logic in a mu-plugin via `fswa_webhook_payload` + `fswa_glue_post_dispatch`. 5. 5 **Shell variables** set shell variables ``` export SITE="https://your-wordpress-site.com" export TOKEN="fswa_full_..." export HS_PAT="pat-eu1-xxxxxxxx..." ``` / Architecture ## Upsert by Email, **Triggered at Registration** HubSpot's contacts API has two write endpoints. `POST /contacts` always creates a new record — useless if the same email registers twice. `POST /contacts/batch/upsert` uses an _idProperty_ (we'll use `email`) to match existing records: it returns the existing contact\_id if found, creates a new one if not. `results[0].new` tells us which case fired. Why hook `user_register` and not the WC order trigger? Because Part 5's order chain self-heals for new emails by upserting in-chain. An order-time contact webhook would duplicate work and race with the deal-create chain. The cleaner split: register-time syncs the contact identity once, order-time relies on the chain to fill in the gap if it doesn't find one. flow diagram ``` # USER REGISTERS — WC account, WP admin, registration form, or programmatic wp_insert_user / wp-admin / WC account / signup form └── fires user_register action with ($user_id, $userdata) └── pre-glue: get_userdata() + get_user_meta() → email, firstname, lastname └── idempotency: if user_meta._hubspot_contact_id already set → return {"inputs":[]} no-op └── Webhook 56 POST /crm/objects/2026-03/contacts/batch/upsert └── HubSpot responds 200 + {"results": [{"id": "781200846026", "new": true, ...}]} └── post-glue: update_user_meta($user_id, _hubspot_contact_id, "781200846026") # Later, when this user places their first WC order: # → Part 5's chain dispatcher reads user_meta._hubspot_contact_id # → Existing-contact PUT path fires (one HubSpot call) # → Without this part, the chain would still work — but via Webhook 55 upsert + Webhook 54 PUT (two calls) ``` **HubSpot does not expose a single-record contacts upsert.** The endpoint `POST /crm/objects/2026-03/contacts/upsert?idProperty=email` returns **405 Method Not Allowed** (verified live). The canonical path is `/contacts/batch/upsert` — the body must wrap a single contact in a one-element `inputs[]` array. Partial upserts are also not supported when `idProperty` is `email`: every send is a full overwrite of the properties you include. FIG 01 — User register → contact upsert / Step 1 ## Create the **"Sync Contact on Register"** Webhook First, store the HubSpot PAT once in the plugin's **Credentials Vault** — webhooks then reference it by id via `auth_credential_id`, which takes precedence over the legacy plaintext `auth_header`. The secret is encrypted at rest and write-only over the API: it never shows up in webhook configs, logs, or agent responses. one-time — store the PAT in the Credentials Vault ``` CRED_ID=$(curl -sk -X POST "$SITE/wp-json/fswa/v1/credentials" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "HubSpot PAT", "type": "bearer", "secret": "'"$HS_PAT"'"}' | jq -r '.id') ``` step 1.1 — create the webhook ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Sync Contact on User Register", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/contacts/batch/upsert", "http_method": "POST", "auth_credential_id": '"$CRED_ID"', "is_enabled": true, "is_synchronous": false, "retry_limit": 2, "triggers": ["user_register"] }' ``` response — webhook ID 56 ``` { "id": "56", "name": "HS - Sync Contact on User Register", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/contacts/batch/upsert", "http_method": "POST" } ``` Why `user_register` and not order triggers `user_register` fires whenever WordPress creates a new user, regardless of source: WC's "Create account" checkbox at checkout, your registration form plugin, manual admin user creation, programmatic `wp_insert_user()` calls. One hook covers them all. **Guest checkouts don't fire `user_register`** — that's intentional. Part 5's chain handles guest orders in-chain via Webhook 55. ### 1.2 — Condition: skip if email is missing Some programmatic user creation paths can land without a usable email. The pre-glue's idempotency guard handles re-fires; this condition handles the "no email at all" edge case. add condition — skip if email is empty ``` curl -sk -X PUT "$SITE/wp-json/fswa/v1/schemas/webhook/56/trigger/user_register" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [{"field": "inputs.0.properties.email", "operator": "is_not_empty"}] }, "conditions_evaluate_on": "transformed" }' ``` **Important — `conditions_evaluate_on` is a TOP-LEVEL schema field**, not nested inside `conditions`. Putting `"evaluate_on": "transformed"` inside the conditions object causes Webhook Actions to silently drop it and evaluate against the original payload — the post-glue field `inputs.0.properties.email` wouldn't exist there and the condition would never match. / Step 2 Filter OK fswa\_webhook\_payloadfswa\_glue\_post\_dispatch Pro ## Read User Data, **Capture the Contact ID** ### 2.1 — Pre-dispatch: read user data, build batch/upsert input snippet code — PHP ``` // Pre-dispatch glue for webhook 56 — fires on user_register. // $payload["args"][0] = user_id. Skip if _hubspot_contact_id already set (idempotency). $userId = (int) ($payload["args"][0] ?? 0); if (!$userId) return ["inputs" => []]; $existing = (string) get_user_meta($userId, "_hubspot_contact_id", true); if ($existing) return ["inputs" => []]; $user = get_userdata($userId); if (!$user) return ["inputs" => []]; $email = strtolower(trim((string) $user->user_email)); if (!$email) return ["inputs" => []]; $first = trim((string) get_user_meta($userId, "first_name", true)); $last = trim((string) get_user_meta($userId, "last_name", true)); // Fall back to splitting display_name if first/last aren't set yet (common at signup). if (!$first && $user->display_name) { $parts = explode(" ", trim((string) $user->display_name), 2); $first = $parts[0] ?? ""; $last = $parts[1] ?? $last; } return [ "_wp_user_id" => (string) $userId, "inputs" => [[ "idProperty" => "email", "id" => $email, "properties" => [ "email" => $email, "firstname" => $first, "lastname" => $last, "lifecyclestage" => "lead", ], ]], ]; ``` **Why lifecyclestage = "lead" instead of "customer"?** They've registered but haven't bought anything yet — "lead" is the canonical pipeline stage. Part 5's order-time chain bumps the contact to "customer" via the upsert on the first order. **Why normalise email to lowercase?** HubSpot's email-based dedupe is case-insensitive but stores the case you send. Lowercasing keeps the value tidy and prevents case-mismatched user-meta lookups later. ### 2.2 — Post-dispatch: store the returned contact ID on user\_meta snippet code — PHP ``` // Post-dispatch glue — read results[0] from the batch response, write to user_meta. if ($responseCode < 200 || $responseCode >= 300) return; $data = json_decode($responseBody, true); $result = $data["results"][0] ?? []; $contactId = (string) ($result["id"] ?? ""); $userId = (int) ($payload["_wp_user_id"] ?? 0); if ($contactId && $userId) { update_user_meta($userId, "_hubspot_contact_id", $contactId); update_user_meta($userId, "_hubspot_contact_was_new", !empty($result["new"]) ? "1" : "0"); } ``` **No order\_meta write here.** `user_register` has no order context — there's no order to attach meta to. Part 5's chain reads from `user_meta` first (preferred) with `order_meta` fallback, so writing `user_meta` alone is enough. / Step 3 ## End-to-End Test — **New, Existing, Re-Fire** **Test 1 — new user registers.** Use any path that creates a WP user with a fresh email: WP admin "Add New User", a registration form plugin, or directly via WP-CLI: `wp user create test1234 test1234@example.com --first_name=Test --last_name=User --role=customer`. Webhook Actions log shows webhook 56 returning `200` with `"results":[{...}]`. HubSpot contact record is created; WP `user_meta` has `_hubspot_contact_id` populated. **Test 2 — existing HubSpot email.** Register a WP user with an email that already exists in HubSpot. Webhook Actions log shows webhook 56 returning `200` with `"results":[{...}]` — HubSpot returned the existing contact\_id. No duplicate. **Test 3 — re-fire idempotency.** For the same WP user, run `do_action('user_register', $user_id, [])` manually via WP-CLI `wp eval`. Pre-glue detects `user_meta._hubspot_contact_id` is already set, returns `"inputs":[]`, condition fails — webhook is skipped with no HubSpot call. Status in the log: `skipped`. HubSpot → CRM → Contacts — the new contact record created by Webhook 56 on `user_register`, with `lifecyclestage = Lead` visible in the About panel. Webhook Actions admin → Logs filtered by webhook 56. First dispatch returns 200 with `results[0].new = true`. The re-fire row below it is _skipped_ — pre-glue idempotency guard short-circuited because `user_meta._hubspot_contact_id` is already set. / Common Pitfalls ## Things **That Bite** **Using `POST /contacts` instead of `/contacts/batch/upsert`.** Plain POST creates duplicates if the email already exists in HubSpot. The single-record `/contacts/upsert?idProperty=email` URL returns **405 Method Not Allowed** — it doesn't exist in the 2026-03 API. Always batch/upsert. **Forgetting the `"id"` field inside `inputs[]`.** The batch/upsert input requires _both_ `idProperty: "email"` AND `id: ""` at the input root. Omitting it returns 400. **Re-firing user\_register breaks the contact.** Some plugins re-fire the action when updating user roles or syncing from external systems — without the pre-glue idempotency guard, every re-fire is a full overwrite of HubSpot properties. Test re-fires explicitly before going live. **Lifecyclestage overwrite on re-fire.** Setting `lifecyclestage = "lead"` can _downgrade_ a contact already at "customer". The pre-glue idempotency guard avoids the overwrite entirely for previously-synced users. **Programmatic user creation skips first\_name/last\_name.** `wp_insert_user()` with just `user_email` + `user_login` creates a user with empty name user\_meta. The pre-glue falls back to splitting `display_name` — but if that's also blank, the HubSpot contact lands with empty name fields. / HubSpot × WooCommerce Series [← Previous Part 3 — Order Line Items](https://wpwebhooks.org/examples/woocommerce-order-line-items-hubspot/) [All 5 parts Series Index](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) [Next → Part 5 — Full Customer × Order Sync](https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/) /Notes → [HubSpot Contacts — create-contact (2026-03)](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/create-contact) → [HubSpot Contacts — guide (covers batch/upsert)](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/guide) → [WordPress Webhook REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/) — full reference for webhooks, schemas, conditions, and logs → [All WordPress webhook automation examples](https://wpwebhooks.org/examples/) /FAQ ## Common questions always ask. Don't see yours? Open an issue on GitHub or check the full reference in the API docs. Why hook user\_register instead of the WC order action? + Part 5's order chain already handles contact creation in-chain via Webhook 55 for new emails. An order-time contact webhook duplicates that work, introduces a race condition with the deal-create chain (the contact webhook is async; the chain dispatches synchronously), and bloats the per-order HubSpot call count. Register-time sync runs once per user lifetime, pre-populates user\_meta, and lets Part 5's chain take the one-call PUT path on the first order. Why use upsert instead of POST for contacts? + HubSpot's POST /contacts 409s on duplicate email. If the registering user's email already exists in HubSpot (prior marketing form, manual entry, imported list), POST fails. /contacts/batch/upsert returns the existing contact\_id instead — exactly what we want for dedupe. Why is it the batch/upsert endpoint when we only ever send one contact? + HubSpot's 2026-03 contacts API does not expose a single-record upsert endpoint — only the batch one. We send a one-element inputs\[\] array per dispatch. The single-record /contacts/upsert URL returns 405 Method Not Allowed. What about guest checkouts — they don't fire user\_register? + Correct — guests don't get a WP user. Part 5's order chain handles guest checkout: Webhook 55 detects empty user\_meta.\_hubspot\_contact\_id and runs the upsert in-chain at order time. This Part 4 webhook only fires for registered users; the two halves cover all cases between them. Does this work without Pro? + Yes — the upsert flow needs no stateful inject. Pro is only required for the post-dispatch ID capture and the pre-dispatch idempotency guard. Without Pro, implement the same flow against fswa\_webhook\_payload + fswa\_glue\_post\_dispatch filters in a mu-plugin. Does upsert preserve unmentioned fields, or overwrite the whole contact? + When idProperty is email, partial upserts are not supported — every send is a full overwrite of the properties you include. Marketing-attribution fields that HubSpot manages internally (hs\_email\_optin, lifecyclestage history, sequence membership) are preserved because we never send them — but never include a property in inputs\[\] that you don't want to authoritatively replace. /More examples ## Related integrations. Part 1 Create & Update Deals from WC Orders The deal half of the customer + order pair that Part 5 ties together. [Read →](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) Part 5 (next) Full Customer × Order Association Associates the contact you just created to the deal from Part 1 — the endgame of the series. [Read →](https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/) Series Hub HubSpot × WooCommerce — All 5 Parts The complete series. [Read →](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"HubSpot WordPress Integration: Sync Contact on User Register","description":"Mirror every WordPress user registration into HubSpot CRM as a Contact — idempotent by email via the 2026-03 batch/upsert endpoint and Code Glue. Pre-populates user_meta so the Part 5 order chain takes the cheap PUT path on first order.","datePublished":"2026-05-15","dateModified":"2026-05-19","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org","sameAs":["https://flowsystems.pl"]},"url":"https://wpwebhooks.org/examples/add-customer-to-hubspot-on-woocommerce-order/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/og_image.jpg"},"keywords":["hubspot wordpress integration","hubspot wordpress contact","woocommerce customer to hubspot","hubspot contact woocommerce","hubspot crm wordpress"],"isPartOf":{"@type":"CreativeWorkSeries","name":"HubSpot × WooCommerce Integration Series","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"}} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why hook user_register instead of the WC order action?","acceptedAnswer":{"@type":"Answer","text":"Part 5's order chain already handles contact creation in-chain via Webhook 55 for new emails. An order-time contact webhook duplicates that work, introduces a race condition with the deal-create chain (the contact webhook is async; the chain dispatches synchronously), and bloats the per-order HubSpot call count. Register-time sync runs once per user lifetime, pre-populates user_meta, and lets Part 5's chain take the one-call PUT path on the first order."}},{"@type":"Question","name":"Why use upsert instead of POST for contacts?","acceptedAnswer":{"@type":"Answer","text":"HubSpot's POST /contacts 409s on duplicate email. If the registering user's email already exists in HubSpot (prior marketing form, manual entry, imported list), POST fails. /contacts/batch/upsert returns the existing contact_id instead — exactly what we want for dedupe."}},{"@type":"Question","name":"Why is it the batch/upsert endpoint when we only ever send one contact?","acceptedAnswer":{"@type":"Answer","text":"HubSpot's 2026-03 contacts API does not expose a single-record upsert endpoint — only the batch one. We send a one-element inputs[] array per dispatch. The single-record /contacts/upsert URL returns 405 Method Not Allowed."}},{"@type":"Question","name":"What about guest checkouts — they don't fire user_register?","acceptedAnswer":{"@type":"Answer","text":"Correct — guests don't get a WP user. Part 5's order chain handles guest checkout: Webhook 55 detects empty user_meta._hubspot_contact_id and runs the upsert in-chain at order time. This Part 4 webhook only fires for registered users; the two halves cover all cases between them."}},{"@type":"Question","name":"Does this work without Pro?","acceptedAnswer":{"@type":"Answer","text":"Yes — the upsert flow needs no stateful inject. Pro is only required for the post-dispatch ID capture and the pre-dispatch idempotency guard. Without Pro, implement the same flow against fswa_webhook_payload + fswa_glue_post_dispatch filters in a mu-plugin."}},{"@type":"Question","name":"Does upsert preserve unmentioned fields, or overwrite the whole contact?","acceptedAnswer":{"@type":"Answer","text":"When idProperty is email, partial upserts are not supported — every send is a full overwrite of the properties you include. Marketing-attribution fields that HubSpot manages internally (hs_email_optin, lifecyclestage history, sequence membership) are preserved because we never send them — but never include a property in inputs[] that you don't want to authoritatively replace."}}]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Examples","item":"https://wpwebhooks.org/examples/"},{"@type":"ListItem","position":3,"name":"HubSpot × WooCommerce","item":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"},{"@type":"ListItem","position":4,"name":"Customer to Contact","item":"https://wpwebhooks.org/examples/add-customer-to-hubspot-on-woocommerce-order/"}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/hubspot-contact-upsert.png","caption":"FIG 01 — User register → contact upsert","description":"When a user registers, a webhook builds a contact in pre-glue and calls the HubSpot contacts batch/upsert endpoint, which is idempotent by email so retries and re-registrations never duplicate; post-glue stores the returned contact id.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "HubSpot WordPress Plugin: Full Customer & Order Sync" description: "The capstone — tie contact, deal, line items, and products into one coherent HubSpot graph on every WooCommerce order, sequenced via Webhook Actions Webhook Chains with batch endpoints. Every hop logged and replayable." url: "https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/" date: "2026-05-15" --- [WP Webhooks](https://wpwebhooks.org/) / [Examples](https://wpwebhooks.org/examples/)/ [HubSpot × WooCommerce](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) / Full Sync # HubSpot WordPress Plugin: Full Customer & Order Sync The capstone. Tie contact (Part 4) ↔ deal (Part 1) ↔ line items (Part 3) ↔ products (Part 2) into one coherent HubSpot graph on every WooCommerce order — using Webhook Actions **Webhook Chains** to sequence each step as a real, observable, replayable webhook. Every hop is its own log row. **~18 min read** May 15, 2026 Part **5** of 5 Filter OK Pro hubspotfull-syncassociations TL;DR — The dual-path chain - **One trigger fans out a 4-link chain.** `woocommerce_checkout_order_created` fires Webhook 31 (sync). On 2xx, the chain dispatches three targets in parallel: line items batch (Part 3), **existing-contact PUT**, and **new-contact upsert**. - **Existing customer** (contact\_id in user\_meta): Webhook 54 fires a single `PUT /crm/objects/2026-03/contacts/{cid}/associations/default/deals/{did}` — one HubSpot call. - **New customer** (no contact\_id): Webhook 55 POSTs to `/contacts/batch/upsert`, then chains forward to Webhook 54 to PUT the association. Two calls. - **Idempotent end-to-end.** Each path stamps `_hubspot_contact_deal_associated` on the order only after the actual PUT lands. / Prerequisites ## All Four Earlier Parts **Working in Isolation** ⚡ Part 5 is the only example in the series that _requires_ all four others. If any of Parts 1-4 isn't returning 2xx in your dev environment, fix that before wiring the graph — debugging four entangled flows at once is misery. 1. 1 **[Part 1](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) shipping `_hubspot_deal_id` on every order** Verified by inspecting recent test order post meta. 2. 2 **Part 2 shipping `_hubspot_product_id` on every product** Verified by inspecting a recently published product. 3. 3 **Part 3 shipping `_hubspot_line_item_ids` on every order** Confirms the post-glue loop is firing and resolving product IDs. 4. 4 **[Part 4](https://wpwebhooks.org/examples/add-customer-to-hubspot-on-woocommerce-order/) (optional) — Sync registered WP users to HubSpot contacts on `user_register`** Pre-populates `user_meta._hubspot_contact_id` so order-time flows hit the cheaper existing-contact PUT path. Part 5 self-heals for new emails in-chain — Part 4 is purely an optimization. 5. 5 **HubSpot scopes: deals, contacts, products, line\_items, plus `crm.objects.contacts.read` + `crm.associations.read`** The associations writes don't need a separate scope — covered by object-write scopes — but reads (verification step) need the read scope. / Architecture ## One Webhook, **Two Conditional Paths** Only Webhook 31 (HS - Create Deal) listens to `woocommerce_checkout_order_created` directly. On its 2xx, the chain dispatcher fires three child webhooks in parallel — but two carry mutually exclusive **conditions** on the transformed payload, so exactly one fires per order based on whether the customer's HubSpot `contact_id` is already known. **Why Webhook 54 appears twice:** it's the same webhook (same PUT method, same URL template), but the target of two different chain links. Each chain link has its own pre-glue snippet — one reads `contact_id` from user\_meta, the other from the upstream upsert response. One webhook config, two pre-glue snippets. full chain diagram ``` # ORDER PLACED — single WC event, single root webhook, chain fans out WC checkout completes └── fires woocommerce_checkout_order_created └── Webhook #31 HS - Create Deal (sync) POST /crm/objects/2026-03/deals → 201 deal_id └── 2xx → ChainDispatcher fires three chain links in parallel: ├── link 11 → Webhook #53 HS - Batch Line Items POST /line_items/batch/create → 201 │ └── always fires (Part 3) │ ├── link 12 → Webhook #54 HS - Associate Existing Contact PUT /contacts/{cid}/associations/default/deals/{did} │ └── condition: _hubspot_contact_id is_not_empty AND _hubspot_deal_id is_not_empty │ └── pre-glue reads contact_id from user_meta or order_meta │ └── link 13 → Webhook #55 HS - Upsert Contact POST /contacts/batch/upsert └── condition: _hubspot_contact_id is_empty AND _hubspot_deal_id is_not_empty └── 2xx → post-glue writes contact_id to user_meta + order_meta └── 2xx → ChainDispatcher fires: └── link 14 → Webhook #54 (again) PUT /contacts/{new_cid}/associations/default/deals/{did} → 200 # HubSpot graph after one order — same end state both paths: # contact ─── deal ───┬─── line_item ─── product # ├─── line_item ─── product # └─── line_item ─── product ``` **Why upsert + chained PUT instead of a single atomic call?** HubSpot's `2026-03/contacts/batch/upsert` endpoint _silently ignores_ inline `associations` arrays (the docs don't surface this — we learned it the hard way). Only `batch/create` supports inline assoc, but `create` 409s on duplicate email. Upsert is the only safe write, so the contact↔deal edge has to be a follow-up chain link — keeping it observable with its own log row, retry, and replay. FIG 01 — Dual-path customer + order sync chain / Step 1 ## Disable Part 4's **Order-Time Contact Webhook** (if you have it) Older versions of Part 4 wired an async contact-upsert webhook onto `woocommerce_checkout_order_created`. The new design moves that work _into_ the chain via Webhook 55, eliminating a race condition. If you have an old Webhook 34 named _HS - Upsert Contact_, disable it. disable order-time contact webhook (if it exists) ``` curl -sk -X PUT "$SITE/wp-json/fswa/v1/webhooks/34" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"is_enabled": false}' ``` / Step 2 Filter OK fswa\_webhook\_payloadfswa\_webhook\_url Pro ## Webhook 54 — **HS - Associate Existing Contact (PUT)** Webhook 54 uses HubSpot's default-association PUT endpoint. It takes the contact ID and deal ID directly in the URL and an empty body — one call, one association, idempotent on replay. One webhook config serves both the existing-customer path (chain link 12) and the new-customer path (chain link 14) via different pre-glue snippets. ### 2.1 — Create the webhook with URL template First, store the HubSpot PAT once in the plugin's **Credentials Vault** — webhooks then reference it by id via `auth_credential_id`, which takes precedence over the legacy plaintext `auth_header`. The secret is encrypted at rest and write-only over the API: it never shows up in webhook configs, logs, or agent responses. one-time — store the PAT in the Credentials Vault ``` CRED_ID=$(curl -sk -X POST "$SITE/wp-json/fswa/v1/credentials" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "HubSpot PAT", "type": "bearer", "secret": "'"$HS_PAT"'"}' | jq -r '.id') ``` create webhook 54 (PUT default association with URL template) ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Associate Existing Contact (PUT)", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/contacts/{{ _hubspot_contact_id }}/associations/default/deals/{{ _hubspot_deal_id }}", "http_method": "PUT", "auth_credential_id": '"$CRED_ID"', "is_enabled": true, "is_synchronous": false, "retry_limit": 2, "triggers": [] }' # → {"id":"54",...} ``` **Use the 2026-03 default-association PUT, not the v4 batch endpoint.** The `/crm/objects/2026-03/{from}/{id}/associations/default/{to}/{id}` path is the simpler call. No request body, no `associationTypeId` bookkeeping, idempotent on replay. Use the v4 batch only when you need labeled or custom association types. ### 2.2 — Pre-dispatch Code Glue: look up contact\_id from user\_meta This snippet runs on chain link 12. It resolves `_hubspot_contact_id` from user\_meta with order\_meta fallback. If the contact isn't known, it returns an empty field — the condition then fails and Webhook 54 doesn't fire. Webhook 55 picks up the new-customer path instead. snippet code — pre-dispatch glue (existing-customer path) ``` // Pre-dispatch glue for webhook 54 (chain link 12, from Webhook 31). // Payload shaping only. Returns _hubspot_contact_id + _hubspot_deal_id as // side-channel fields that the URL template and condition read. $chain = $payload["args"][0] ?? []; $dealId = (string) ($chain["response"]["body"]["id"] ?? ""); $wcOrderId = (int) ( $chain["payload"]["_wc_order_id"] ?? $chain["payload"]["args"][0] ?? $chain["original_payload"]["args"][0] ?? 0 ); $contactId = ""; $alreadyAssociated = false; if ($dealId && $wcOrderId) { $order = wc_get_order($wcOrderId); if ($order) { $alreadyAssociated = (bool) $order->get_meta("_hubspot_contact_deal_associated"); $userId = (int) $order->get_user_id(); if ($userId) { $contactId = (string) get_user_meta($userId, "_hubspot_contact_id", true); } if (!$contactId) { $contactId = (string) $order->get_meta("_hubspot_contact_id"); } } } // Idempotency: if already associated, zero out deal_id so the condition fails. if ($alreadyAssociated) { return [ "_hubspot_contact_id" => $contactId, "_hubspot_deal_id" => "", "_wc_order_id" => (string) $wcOrderId, ]; } return [ "_hubspot_contact_id" => $contactId, "_hubspot_deal_id" => $dealId, "_wc_order_id" => (string) $wcOrderId, ]; ``` ### 2.3 — Post-dispatch Code Glue: stamp the replay marker snippet code — post-dispatch glue ``` // Stamps the idempotency marker only on 2xx. Reused for chain link 14. if ($responseCode < 200 || $responseCode >= 300) return; $orderId = (int) ($payload["_wc_order_id"] ?? 0); if (!$orderId) return; $order = wc_get_order($orderId); if ($order) { $order->update_meta_data("_hubspot_contact_deal_associated", gmdate("c")); $order->save(); } ``` ### 2.4 — Wire as chain target + add the condition add chain link 31 → 54 (assumes chain 10 from Part 3) ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/chains/10/links" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"source_webhook_id": 31, "target_webhook_id": 54}' # → {"id":"12",...} ``` set condition on the chain trigger (evaluate against transformed payload) ``` curl -sk -X PUT "$SITE/wp-json/fswa/v1/schemas/webhook/54/trigger/fswa_chain_link%3A12" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [ {"field": "_hubspot_deal_id", "operator": "is_not_empty", "value": "", "cast": "string"}, {"field": "_hubspot_contact_id", "operator": "is_not_empty", "value": "", "cast": "string"} ] }, "conditions_evaluate_on": "transformed" }' ``` / Step 3 Filter OK fswa\_webhook\_payloadfswa\_glue\_post\_dispatch Pro ## Webhook 55 — **HS - Upsert Contact (new-customer path)** For orders where the customer has no `_hubspot_contact_id` in user\_meta yet, Webhook 55 creates the contact via `contacts/batch/upsert`. Then chain link 14 fires Webhook 54 again — same webhook config, but with a different pre-glue snippet that reads the freshly-created contact\_id from the upstream upsert response. ### 3.1 — Create the upsert webhook create webhook 55 ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Upsert Contact + Associate to Deal (POST)", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/contacts/batch/upsert", "http_method": "POST", "auth_credential_id": '"$CRED_ID"', "is_enabled": true, "is_synchronous": false, "retry_limit": 2, "triggers": [] }' # → {"id":"55",...} ``` ### 3.2 — Pre-dispatch glue: build the contact upsert body snippet code — pre-dispatch glue ``` // Pre-dispatch glue for webhook 55. Resolves contact_id (for the condition // check) AND builds the upsert input from WC billing data. $chain = $payload["args"][0] ?? []; $dealId = (string) ($chain["response"]["body"]["id"] ?? ""); $wcOrderId = (int) ( $chain["payload"]["_wc_order_id"] ?? $chain["payload"]["args"][0] ?? $chain["original_payload"]["args"][0] ?? 0 ); if (!$dealId || !$wcOrderId) { return ["_hubspot_contact_id" => "skip", "_hubspot_deal_id" => "", "inputs" => []]; } $order = wc_get_order($wcOrderId); if (!$order) { return ["_hubspot_contact_id" => "skip", "_hubspot_deal_id" => "", "inputs" => []]; } $alreadyAssociated = (bool) $order->get_meta("_hubspot_contact_deal_associated"); $userId = (int) $order->get_user_id(); $contactId = ""; if ($userId) { $contactId = (string) get_user_meta($userId, "_hubspot_contact_id", true); } if (!$contactId) { $contactId = (string) $order->get_meta("_hubspot_contact_id"); } if ($alreadyAssociated) { return [ "_hubspot_contact_id" => $contactId ?: "skip", "_hubspot_deal_id" => "", "inputs" => [], ]; } $email = strtolower(trim($order->get_billing_email())); $phone = preg_replace('/[^d+]/', '', $order->get_billing_phone()); return [ "_hubspot_contact_id" => $contactId, // empty for new customer → condition is_empty passes "_hubspot_deal_id" => $dealId, "_wc_order_id" => (string) $wcOrderId, "_wc_user_id" => (string) $userId, "inputs" => [[ "idProperty" => "email", "id" => $email, "properties" => [ "email" => $email, "firstname" => trim($order->get_billing_first_name()), "lastname" => trim($order->get_billing_last_name()), "phone" => $phone, "company" => trim($order->get_billing_company()), "city" => trim($order->get_billing_city()), "country" => trim($order->get_billing_country()), "lifecyclestage" => "customer", ], ]], ]; ``` **HubSpot's contacts/batch/upsert _silently ignores_ inline `associations` arrays.** Only `batch/create` supports inline assoc — `batch/upsert` drops the field with no warning and returns 200. This is why we need the follow-up PUT via chain link 14. ### 3.3 — Post-dispatch glue: store contact\_id (but NOT the assoc marker) Webhook 55's post-glue writes the new contact\_id to user\_meta + order\_meta. **It must NOT stamp `_hubspot_contact_deal_associated`** — that marker is reserved for Webhook 54's success. If we stamped it here, the follow-up chain link 14 would short-circuit before doing the actual association. snippet code — post-dispatch glue ``` if ($responseCode < 200 || $responseCode >= 300) return; $data = json_decode($responseBody, true); $result = $data["results"][0] ?? []; $contactId = (string) ($result["id"] ?? ""); $orderId = (int) ($payload["_wc_order_id"] ?? 0); $userId = (int) ($payload["_wc_user_id"] ?? 0); if (!$contactId || !$orderId) return; $order = wc_get_order($orderId); if ($order) { $order->update_meta_data("_hubspot_contact_id", $contactId); $order->save(); } if ($userId) { update_user_meta($userId, "_hubspot_contact_id", $contactId); } ``` ### 3.4 — Wire as third chain target of Webhook 31 + opposite condition add chain link 31 → 55 ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/chains/10/links" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"source_webhook_id": 31, "target_webhook_id": 55}' # → {"id":"13",...} ``` condition: fires only when contact\_id is EMPTY (new customer) ``` curl -sk -X PUT "$SITE/wp-json/fswa/v1/schemas/webhook/55/trigger/fswa_chain_link%3A13" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [ {"field": "_hubspot_deal_id", "operator": "is_not_empty", "value": "", "cast": "string"}, {"field": "_hubspot_contact_id", "operator": "is_empty", "value": "", "cast": "string"} ] }, "conditions_evaluate_on": "transformed" }' ``` ### 3.5 — Chain Webhook 54 from Webhook 55 (the follow-up PUT) add chain link 55 → 54 ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/chains/10/links" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"source_webhook_id": 55, "target_webhook_id": 54}' # → {"id":"14",...} ``` snippet code — webhook 54 pre-glue for chain link 14 ``` // Pre-glue for webhook 54 when chained from webhook 55 (post-upsert). $chain = $payload["args"][0] ?? []; $body = $chain["response"]["body"] ?? []; $contactId = (string) ($body["results"][0]["id"] ?? ""); $dealId = (string) ($chain["payload"]["_hubspot_deal_id"] ?? ""); $wcOrderId = (int) ($chain["payload"]["_wc_order_id"] ?? 0); if ($wcOrderId) { $order = wc_get_order($wcOrderId); if ($order && $order->get_meta("_hubspot_contact_deal_associated")) { return [ "_hubspot_contact_id" => $contactId, "_hubspot_deal_id" => "", // idempotency: skip "_wc_order_id" => (string) $wcOrderId, ]; } } return [ "_hubspot_contact_id" => $contactId, "_hubspot_deal_id" => $dealId, "_wc_order_id" => (string) $wcOrderId, ]; ``` Webhook Actions admin → Webhooks list with the _HS - Order Line Items_ chain group expanded. Webhook 31 fans out to three targets (53 batch line items, 54 existing-PUT, 55 upsert). Webhook 55 chains forward to webhook 54 a second time via chain link 14. / Step 4 ## End-to-End Test — **Both Paths** ### 4.1 — New customer path Place a test order with a fresh billing email. Filter Webhook Actions Logs by chain _HS - Order Line Items_ — you should see 5 rows: Webhook 31 (201) → Webhook 53 line items (201) → Webhook 54 link 12 (_skipped — condition not met_) → Webhook 55 upsert (200) → Webhook 54 link 14 (200). ### 4.2 — Existing customer path Place a second order using the same email. You should see 4 rows: Webhook 31 (201) → Webhook 53 (201) → Webhook 54 link 12 (200) → Webhook 55 (_skipped — condition not met_). Open the deal in HubSpot — contact attached, line items listed, the full graph complete via either path. HubSpot → Deal record. Right sidebar shows the associated Contact + 3 Line Items + 3 Products, the full graph wired by one WC order placement. Webhook Actions admin → Logs filtered by chain _HS - Order Line Items_ for one new-customer order. Five rows: deal create (201) → line items batch (201) → link 12 skipped → upsert (200) → link 14 (200). / Common Pitfalls ## Things **That Bite** **HubSpot's `contacts/batch/upsert` silently ignores inline associations.** The 2026-03 object batch APIs accept an `associations` array per input on _create_, but `upsert` drops the field with no warning. Verify by querying `GET /crm/v4/objects/deals/{id}/associations/contacts` after the upsert. **Post-glue must NOT stamp the assoc marker on Webhook 55.** If you stamp `_hubspot_contact_deal_associated` after the upsert, the follow-up Webhook 54 (chain link 14) skips — contact created, no association. **URL template variables come from the transformed payload.** `{{ _hubspot_contact_id }}` is resolved against whatever pre-glue returns — not the chain payload, not order meta directly. Pre-glue is the single source of truth. **HubSpot rate limits.** Worst case (new customer, 50-item cart) = 4 HubSpot calls total: deal create, line items batch, contact upsert, contact↔deal PUT. Well within the default 110 req/10s burst on standard HubSpot plans. / HubSpot × WooCommerce Series [← Previous Part 4 — Sync Contact on Register](https://wpwebhooks.org/examples/add-customer-to-hubspot-on-woocommerce-order/) [All 5 parts Series Index](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) Next → (Series end) /Notes → [HubSpot Associations API](https://developers.hubspot.com/docs/api-reference/latest/crm/associations/overview) → [HubSpot API usage guidelines](https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines) → [WordPress Webhook REST API reference](https://wpwebhooks.org/webhook-wordpress-plugin-api/) → [All WordPress webhook automation examples](https://wpwebhooks.org/examples/) /FAQ ## Common questions always ask. Don't see yours? Open an issue on GitHub or check the full reference in the API docs. Why is the full hubspot wordpress plugin sync split into 5 parts and not one giant snippet? + Each part is independently useful and independently debuggable. Bugs in line items don't take down deal creation; product sync can run on its own schedule. Splitting along clean object boundaries means each piece can be replayed without rerunning the whole pipeline. Why does Webhook 54 appear twice in the chain? + It's the same webhook wired as the target of two chain links. Each link has its own pre-glue snippet attached — one reads \_hubspot\_contact\_id from user\_meta (existing-customer path), the other from the upstream upsert response (new-customer path). FSWA's snippet attachment is per-(webhook, trigger), so the same webhook serves multiple roles via different chain-link triggers. Why upsert + PUT instead of one atomic call? + HubSpot's 2026-03 contacts/batch/upsert silently ignores inline associations arrays — only batch/create supports them, but create 409s on duplicate email. Upsert is the only safe write path for new-or-existing contacts, so the association has to be a follow-up. Modeling that as a chain link keeps it observable: own log row, own retry, own replay. What if I want only one HubSpot association to fire (e.g. contact-deal but not line-items)? + Disable the chain link you don't want via DELETE /fswa/v1/chains/{cid}/links/{lid}. Each chain link is independent. The target webhook itself stays — you can still trigger it manually or from a different chain. How do I handle order edits after the initial sync? + The series targets first-write only. For edits, listen to woocommerce\_order\_status\_changed (already wired in Part 1 for Closed Won) plus woocommerce\_update\_order, and write a sync-update post-glue that PATCHes the deal and refreshes line items only if the line-items list has changed (compare to \_hubspot\_line\_item\_ids meta). Is this the same as HubSpot's official WooCommerce integration? + No. The official integration uses a fixed schema and ships a defined object graph. This setup lets you choose exactly which associations exist, which conditions apply, and gives you a full Webhook Actions delivery log with replay — including the sub-calls for line items and associations. /More examples ## Related integrations. Part 1 Create & Update Deals from WC Orders The deal-create webhook this part extends with the final contact-deal association block. [Read →](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) Bonus Ask Claude Code to Set Up a WC Webhook A real Claude Code session — REST API discovery, payload-path correction, live fix. [Read →](https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/) Series Hub HubSpot × WooCommerce — All 5 Parts Back to the series index. [Read →](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"HubSpot WordPress Plugin: Full Customer and Order Sync","description":"Tie HubSpot contact, deal, line items, and products into a single coherent graph for every WooCommerce order — using Webhook Actions Webhook Chains and HubSpot batch endpoints. The hubspot wordpress plugin endgame.","datePublished":"2026-05-15","dateModified":"2026-05-19","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org","sameAs":["https://flowsystems.pl"]},"url":"https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/og_image.jpg"},"keywords":["hubspot wordpress plugin","hubspot wordpress integration","hubspot woocommerce full sync","hubspot crm wordpress","hubspot association woocommerce"],"isPartOf":{"@type":"CreativeWorkSeries","name":"HubSpot × WooCommerce Integration Series","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"}} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why is the full hubspot wordpress plugin sync split into 5 parts and not one giant snippet?","acceptedAnswer":{"@type":"Answer","text":"Each part is independently useful and independently debuggable. Bugs in line items don't take down deal creation; product sync can run on its own schedule. Splitting along clean object boundaries means each piece can be replayed without rerunning the whole pipeline."}},{"@type":"Question","name":"Why does Webhook 54 appear twice in the chain?","acceptedAnswer":{"@type":"Answer","text":"It's the same webhook wired as the target of two chain links. Each link has its own pre-glue snippet attached — one reads _hubspot_contact_id from user_meta (existing-customer path), the other from the upstream upsert response (new-customer path). FSWA's snippet attachment is per-(webhook, trigger), so the same webhook serves multiple roles via different chain-link triggers."}},{"@type":"Question","name":"Why upsert + PUT instead of one atomic call?","acceptedAnswer":{"@type":"Answer","text":"HubSpot's 2026-03 contacts/batch/upsert silently ignores inline associations arrays — only batch/create supports them, but create 409s on duplicate email. Upsert is the only safe write path for new-or-existing contacts, so the association has to be a follow-up. Modeling that as a chain link keeps it observable: own log row, own retry, own replay."}},{"@type":"Question","name":"What if I want only one HubSpot association to fire (e.g. contact-deal but not line-items)?","acceptedAnswer":{"@type":"Answer","text":"Disable the chain link you don't want via DELETE /fswa/v1/chains/{cid}/links/{lid}. Each chain link is independent. The target webhook itself stays — you can still trigger it manually or from a different chain."}},{"@type":"Question","name":"How do I handle order edits after the initial sync?","acceptedAnswer":{"@type":"Answer","text":"The series targets first-write only. For edits, listen to woocommerce_order_status_changed (already wired in Part 1 for Closed Won) plus woocommerce_update_order, and write a sync-update post-glue that PATCHes the deal and refreshes line items only if the line-items list has changed (compare to _hubspot_line_item_ids meta)."}},{"@type":"Question","name":"Is this the same as HubSpot's official WooCommerce integration?","acceptedAnswer":{"@type":"Answer","text":"No. The official integration uses a fixed schema and ships a defined object graph. This setup lets you choose exactly which associations exist, which conditions apply, and gives you a full Webhook Actions delivery log with replay — including the sub-calls for line items and associations."}}]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Examples","item":"https://wpwebhooks.org/examples/"},{"@type":"ListItem","position":3,"name":"HubSpot × WooCommerce","item":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"},{"@type":"ListItem","position":4,"name":"Full Sync","item":"https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/"}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/hubspot-full-sync-chain.png","caption":"FIG 01 — Dual-path customer + order sync chain","description":"One order trigger fans out a chain. Existing customers get a single PUT contact-to-deal association; new customers get a contacts upsert then the PUT. The deal is created or updated, and a Webhook Chain then batch-creates line items with inline associations — idempotent end to end.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "HubSpot WordPress Plugin: WooCommerce Integration Series" description: "The complete HubSpot WordPress plugin for WooCommerce — five examples covering deals, products, line items, customers, and the full HubSpot graph." url: "https://wpwebhooks.org/examples/hubspot-woocommerce-integration/" --- [WP Webhooks](https://wpwebhooks.org/) / [Examples](https://wpwebhooks.org/examples/) / HubSpot × WooCommerce # HubSpot WordPress Plugin WooCommerce Integration Series A complete HubSpot WordPress plugin walkthrough for WooCommerce stores. Five examples, one stack — deals, products, line items, contacts, and the full HubSpot object graph. No Zapier. No middleware. WordPress → HubSpot directly. hubspotwoocommerceseries HubSpot's official hubspot plugin wordpress integration handles the basics — form-to-contact capture and a few stock fields. The moment you need more (deal creation on a specific WooCommerce event, line items mirrored from your product catalog, contact-deal associations on order completion), the official plugin's fixed schema runs out. This series uses [Webhook Actions](https://wpwebhooks.org/wordpress-webhook-plugin/) as the HubSpot WordPress plugin layer: a free WordPress plugin that ships any WordPress event to any HTTP endpoint, with retry, logging, conditions, and a full REST API. The two features that make stateful HubSpot flows possible — Code Glue (PHP snippets that run pre and post dispatch) and dynamic URL templates (`{{ field.path }}` in endpoint URLs) — are part of the free plugin from v3.0.0 on. **Snippets are the convenient path, not the only path.** Every step in this series can also be written as code you deploy yourself — the same flow in a mu-plugin against three documented hooks: `fswa_webhook_payload` (pre-dispatch payload shaping), `fswa_webhook_url` (URL template expansion), and the `fswa_glue_post_dispatch` action (post-dispatch side effects). Look for the blue Filter OK badge on a step — it names the hooks that do the same job as that step's snippet. Worth knowing on hosts that set `DISALLOW_FILE_EDIT`, where WordPress will not let the admin write a snippet at all. Each example below is independent but builds on the previous one. Part 5 ties everything together into a single coherent integration: order placed → deal created → line items mirrored → contact created → everything associated → deal moved to Closed Won on order completion. / The Series ## Five **Examples** Part 1 of 5 Create & Update Deals from WooCommerce Orders Build the canonical HubSpot WooCommerce integration: create a HubSpot deal when a WC order is placed, move it to Closed Won when the order completes. Uses the new 2026-03 HubSpot endpoint and Code Glue. ~12 min [Read →](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) Part 2 of 5 Sync WooCommerce Products to HubSpot Push WooCommerce products to HubSpot on publish (POST), keep them in sync on update (PATCH). The two-webhook condition split that keeps repeat edits from duplicating. ~10 min [Read →](https://wpwebhooks.org/examples/hubspot-product-sync-woocommerce/) Part 3 of 5 Order Line Items in HubSpot When a deal is created (Part 1), iterate WC line\_items, POST each as a HubSpot line\_item with its hs\_product\_id (from Part 2), and associate to the deal. ~10 min [Read →](https://wpwebhooks.org/examples/woocommerce-order-line-items-hubspot/) Part 4 of 5 Sync Contact on User Register Mirror every WordPress user registration into HubSpot CRM as a Contact — idempotent by email via the 2026-03 batch/upsert endpoint. Optional companion to Part 5: pre-populates user\_meta so the order chain hits the cheap PUT path on day one. ~10 min [Read →](https://wpwebhooks.org/examples/add-customer-to-hubspot-on-woocommerce-order/) Part 5 of 5 Full Customer × Order Sync Tie it all together with a dual-path chain. The deal-create fans out: existing customer → PUT contact↔deal association (one HubSpot call); new customer → POST upsert contact, then PUT association (two calls). Every WordPress order becomes a complete HubSpot graph. ~18 min [Read →](https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/) /FAQ ## Common questions always ask. Don't see yours? Open an issue on GitHub or check the full reference in the API docs. Is Webhook Actions a HubSpot WordPress integration plugin? + Webhook Actions is a general-purpose WordPress webhook plugin — it ships any WordPress event to any HTTP endpoint. HubSpot is one of the most common targets, so we maintain this five-part series of HubSpot WordPress integration examples covering deals, products, line items, contacts, and the full HubSpot object graph. Does this replace HubSpot's official WordPress plugin? + It serves a different need. The official HubSpot WordPress plugin pushes data on a fixed schema. Webhook Actions lets you define exactly which WP/WC events become which HubSpot writes, on which conditions, with a full delivery log and replay. Most teams that outgrow the official plugin land here. Is the HubSpot WordPress plugin integration free? + The free plugin handles all one-way push flows — create-deal, push-product, create-contact, send custom events. The Pro plugin adds Code Glue (PHP snippets for stateful flows) and dynamic URL templates, which are needed when one webhook depends on an ID returned by an earlier webhook. Every Pro example in this series can also be built on the free plugin alone via three documented hooks: fswa\_webhook\_payload, fswa\_webhook\_url, and the fswa\_glue\_post\_dispatch action — Pro just packages them as admin-editable snippets. What is the difference between HubSpot CRM WordPress and full HubSpot integration? + HubSpot CRM WordPress typically refers to one-way contact capture (forms → contacts). Full HubSpot integration covers the whole object graph: contacts, companies, deals, products, line items, and the associations between them. This series builds toward the full graph by Part 5. Can I run this hubspot plugin wordpress series on any WordPress site? + Yes. The series targets WooCommerce specifically, but the underlying Webhook Actions plugin works on any WordPress event — ACF, user registration, custom post types, custom actions. The HubSpot side just needs a Private App access token with the relevant scopes. /More examples ## Other integrations. Start here Part 1: Create & Update Deals The entry point to the series — deals, webhooks, and the 2026-03 HubSpot endpoint. [Read →](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) Tutorial Ask Claude Code to Set Up a WooCommerce Webhook A real Claude Code session — REST API discovery, payload-path correction, and a live fix before shipping. [Read →](https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/) All examples WordPress Webhook Automation Examples Every example: forms, CRMs, WooCommerce triggers, and more. [Read →](https://wpwebhooks.org/examples/) ## Structured data ```json {"@context":"https://schema.org","@type":"CollectionPage","name":"HubSpot WordPress Plugin: WooCommerce Integration Series","description":"A five-part series building a complete HubSpot WooCommerce integration with the Webhook Actions WordPress plugin.","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/","isPartOf":{"@type":"WebSite","name":"WP Webhooks","url":"https://wpwebhooks.org"},"keywords":["hubspot wordpress plugin","hubspot plugin wordpress","hubspot wordpress","hubspot woocommerce integration","hubspot crm wordpress","hubspot integration wordpress","hubspot with wordpress","hubspot and woocommerce integration","hubspot plugins"],"hasPart":[{"@type":"Article","name":"Create & Update Deals from WooCommerce Orders","url":"https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/"},{"@type":"Article","name":"Sync WooCommerce Products to HubSpot","url":"https://wpwebhooks.org/examples/hubspot-product-sync-woocommerce/"},{"@type":"Article","name":"Order Line Items in HubSpot","url":"https://wpwebhooks.org/examples/woocommerce-order-line-items-hubspot/"},{"@type":"Article","name":"Sync Contact on User Register","url":"https://wpwebhooks.org/examples/add-customer-to-hubspot-on-woocommerce-order/"},{"@type":"Article","name":"Full Customer & Order Sync","url":"https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is Webhook Actions a HubSpot WordPress integration plugin?","acceptedAnswer":{"@type":"Answer","text":"Webhook Actions is a general-purpose WordPress webhook plugin — it ships any WordPress event to any HTTP endpoint. HubSpot is one of the most common targets, so we maintain this five-part series of HubSpot WordPress integration examples covering deals, products, line items, contacts, and the full HubSpot object graph."}},{"@type":"Question","name":"Does this replace HubSpot's official WordPress plugin?","acceptedAnswer":{"@type":"Answer","text":"It serves a different need. The official HubSpot WordPress plugin pushes data on a fixed schema. Webhook Actions lets you define exactly which WP/WC events become which HubSpot writes, on which conditions, with a full delivery log and replay. Most teams that outgrow the official plugin land here."}},{"@type":"Question","name":"Is the HubSpot WordPress plugin integration free?","acceptedAnswer":{"@type":"Answer","text":"The free plugin handles all one-way push flows — create-deal, push-product, create-contact, send custom events. The Pro plugin adds Code Glue (PHP snippets for stateful flows) and dynamic URL templates, which are needed when one webhook depends on an ID returned by an earlier webhook. Every Pro example in this series can also be built on the free plugin alone via three documented hooks: fswa_webhook_payload, fswa_webhook_url, and the fswa_glue_post_dispatch action — Pro just packages them as admin-editable snippets."}},{"@type":"Question","name":"What is the difference between HubSpot CRM WordPress and full HubSpot integration?","acceptedAnswer":{"@type":"Answer","text":"HubSpot CRM WordPress typically refers to one-way contact capture (forms → contacts). Full HubSpot integration covers the whole object graph: contacts, companies, deals, products, line items, and the associations between them. This series builds toward the full graph by Part 5."}},{"@type":"Question","name":"Can I run this hubspot plugin wordpress series on any WordPress site?","acceptedAnswer":{"@type":"Answer","text":"Yes. The series targets WooCommerce specifically, but the underlying Webhook Actions plugin works on any WordPress event — ACF, user registration, custom post types, custom actions. The HubSpot side just needs a Private App access token with the relevant scopes."}}]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Examples","item":"https://wpwebhooks.org/examples/"},{"@type":"ListItem","position":3,"name":"HubSpot × WooCommerce","item":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"}]} ``` --- --- title: "IvyForms Webhook Plugin: Send Forms to n8n with AI" description: "IvyForms webhook plugin setup: the built-in IvyForms integration plus Build with AI sends form submissions to n8n with field mapping and automatic retry." url: "https://wpwebhooks.org/examples/ivyforms-to-webhook/" date: "2026-03-28" --- [WP Webhooks](https://wpwebhooks.org/) / [Examples](https://wpwebhooks.org/examples/) / IvyForms to Webhook # IvyForms Webhooks: Built-In Integration, Configured by AI IvyForms doesn't include a built-in webhook feature — but Webhook Actions ships a native IvyForms integration. Describe the n8n forwarding you want to Build with AI and the agent wires it up; or click through five manual steps. Either way: queued delivery, retries, and a full event log. **~5 min read** Updated Jul 17, 2026 ivyformswebhookn8n TL;DR - IvyForms has no native webhook — **Build with AI** wires the plugin's built-in `ivyforms/form/after_submission` integration to n8n from one sentence - The webhook is created **disabled**; you review the plan, confirm enable, and test with a real submission - Delivery is queued with automatic retries (1 min, 2 min, 4 min, 8 min — 5 attempts), full event log, and one-click replay / Build with AI ## Describe It Once — **The Agent Builds It** ⚡ This example uses [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/) — a free WordPress plugin whose admin opens on the **Build with AI** screen: describe the integration or automation you want, and the agent proposes a plan you can edit, then builds and tests it for you. Open **Webhook Actions** in the WordPress admin and type: prompt — paste into Build with AI ``` When an IvyForms form is submitted, send it as JSON to my n8n webhook: https://your-n8n-url/webhook/test ``` Build with AI — describe the IvyForms → n8n integration in plain language and press **Build** The agent gathers context first — you'll see read-only ability chips like `list_triggers` and `get_trigger_schema`. IvyForms is one of the plugin's built-in integrations, so the agent selects `ivyforms/form/after_submission` itself and knows the payload shape it produces. It then proposes an ordered plan: create the webhook, point it at your n8n URL, and map the numeric field IDs to readable keys. With **"Review plan before running"** enabled you can edit any step before it executes. The proposed plan — trigger, endpoint, and field mapping as editable steps before anything is created When the run finishes you get **"Build complete."** with a one-click enable toggle; new webhooks are always created disabled until you confirm. Build complete — review the result, flip the enable toggle, and submit a test form / Manual Setup ## Prefer to Set It Up **by Hand?** The same result takes five steps in the admin UI — no AI provider needed: 1. 1 **Install the plugin** Search for **FlowSystems** in **Plugins → Add Plugin** — it narrows the WordPress.org search to exactly one result. 2. 2 **Create a new webhook** Go to **Webhooks → Add Webhook**, name it (e.g., "IvyForms → n8n"). 3. 3 **Select the trigger** Set the WordPress action hook to `ivyforms/form/after_submission` — it fires once per successful IvyForms submission. action hook — paste into the trigger field ``` ivyforms/form/after_submission ``` Webhook Actions admin — trigger set to `ivyforms/form/after_submission`, with a captured IvyForms payload visible in the Payload Mapping panel on the right 4. 4 **Set the n8n webhook URL** In n8n, add a **Webhook** node, set HTTP Method to `POST`, and copy its URL into the plugin's webhook URL field. 5. 5 **Save and test** Submit your IvyForms form and check the **Event Log** for the delivery status and payload. In n8n, "Listen for test event" shows the incoming data for mapping to subsequent nodes. Webhook Actions event log — IvyForms submission delivered successfully; the Log Details panel shows the full payload that was sent Writing the integration yourself against the hook? The signature (4 args: `$formId`, `$submissionData`, `$formFields`, `$entryId`), a working `wp_remote_post()` example, and the field-ID-to-label mapping are covered in the [ivyforms/form/after\_submission hook reference](https://wpwebhooks.org/blog/ivyforms-after-submission-webhook/). / Payload ## Example **Payload** Here's what a real IvyForms submission looks like when it arrives at your n8n webhook. `args[1]` contains the submitted values keyed by **numeric field ID** (not field name), plus `formId`, `postId`, and `referer`. `args[2]` is the full field definition array — use it to map IDs to human-readable labels. POST body — application/json ``` { "event": { "id": "c3194779-575a-432e-be87-2e739c4a7a5b", "timestamp": "2026-03-27T21:59:03Z", "version": "1.0" }, "hook": "ivyforms/form/after_submission", "args": [ 1, { "1": "Mateusz", "2": "mateusz@flowsystems.pl", "3": "testing webhook", "formId": 1, "postId": 20, "referer": "" }, [ { "__type": "IvyForms\Entity\Field\Field", "id": 1, "label": "Name", "type": "text", "required": true, "value": "Mateusz" }, { "__type": "IvyForms\Entity\Field\Field", "id": 2, "label": "Email", "type": "email", "required": true, "value": "mateusz@flowsystems.pl" }, { "__type": "IvyForms\Entity\Field\Field", "id": 3, "label": "Message", "type": "textarea", "required": false, "value": "testing webhook" } ], 3 ], "timestamp": 1774648743, "site": { "url": "https://webhook-actions.local" } } ``` `args[0]` is the form ID. `args[1]` is the submission data — field values are keyed by **numeric field ID** (`"1"`, `"2"`, `"3"`…), not by label. `args[2]` is the field definitions array — each object has `id`, `label`, `type`, and `value`. `args[3]` is the entry ID (`null` if entry storage is disabled in IvyForms). Let Build with AI apply a field mapping and n8n receives flat keys like `name` and `email` instead. / Reliability ## What You Get **Beyond the Setup** Every delivery is dispatched from a background queue, so form submissions are never blocked by a slow or unavailable endpoint. Failures retry automatically with exponential backoff — 1 min, 2 min, 4 min, 8 min, 5 attempts — and every attempt is logged with status code and response body. Anything that exhausts its retries stays in the event log, replayable from the admin UI or via [the REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/) (`POST /wp-json/fswa/v1/logs/{id}/retry`) — without asking the user to resubmit. Webhook Actions event log — IvyForms delivery failed with HTTP 500; next retry already scheduled automatically, full payload preserved for replay Why this matters in production — and why bare `wp_remote_post()` integrations lose submissions silently — is covered in [Why WordPress webhooks silently fail in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) and [How the retry and replay system works](https://wpwebhooks.org/blog/wordpress-webhook-retry-replay-system/). /Notes → [Webhook Actions by Flow Systems](https://wpwebhooks.org/wordpress-webhook-plugin/) — plugin overview → [ivyforms/form/after\_submission hook reference](https://wpwebhooks.org/blog/ivyforms-after-submission-webhook/) — parameters, field-ID mapping, and code examples → [WordPress Webhook REST API](https://wpwebhooks.org/webhook-wordpress-plugin-api/) — retry and replay documentation → [Why WordPress webhooks silently fail in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) → [All WordPress webhook automation examples](https://wpwebhooks.org/examples/) /FAQ ## Common questions always ask. Don't see yours? Open an issue on GitHub or check the full reference in the API docs. Can AI set up my IvyForms webhook? + Yes. The free Webhook Actions by Flow Systems plugin includes Build with AI — describe the integration in plain language ("When an IvyForms form is submitted, send it as JSON to my n8n webhook"), and the agent selects the plugin's built-in IvyForms trigger, proposes a plan you can edit, then creates the webhook with endpoint and field mapping configured. New webhooks are created disabled until you confirm. Does IvyForms support webhooks natively? + Not natively. IvyForms doesn't ship with a built-in webhook feature. To send form data to a webhook endpoint you need custom code using the ivyforms/form/after\_submission action hook, or a plugin that handles webhook dispatch for you — Webhook Actions by Flow Systems includes a built-in IvyForms integration. How do I add a webhook to IvyForms? + The fastest way is the Build with AI screen in the Webhook Actions by Flow Systems plugin: describe the integration in one sentence and the agent builds it on the plugin's built-in IvyForms integration. Manually, you create a webhook in the plugin admin, select ivyforms/form/after\_submission as the trigger, and paste your endpoint URL. Either way you get queued delivery with automatic retries and logging. What happens if my n8n webhook is down when IvyForms submits? + With a bare wp\_remote\_post() call, the data is lost silently — there's no retry and no log entry. With a queue-based system, the failed delivery is stored and retried automatically with exponential backoff (1 min, 2 min, 4 min, 8 min — 5 attempts). You can also replay any failed submission manually from the WordPress admin. Can I retry failed IvyForms webhook deliveries? + Yes, if you use a plugin with retry support. Webhook Actions by Flow Systems retries failed deliveries automatically and also exposes a REST API endpoint (POST /wp-json/fswa/v1/logs/{id}/retry) that lets you replay any past submission — including successful ones. /More examples ## Related integrations. Contact Form 7 CF7 to Webhook: Plugin Setup with AI Contact Form 7 has no built-in webhooks — describe the integration to Build with AI and it wires the trigger, mapping, and retries to n8n. [Read →](https://wpwebhooks.org/examples/cf7-to-webhook/) Gravity Forms Gravity Forms Webhook Plugin: Build It with AI No Webhooks Add-On required — describe the integration to Build with AI and it wires gform\_after\_submission to any endpoint with retries and a full event log. [Read →](https://wpwebhooks.org/examples/gravity-forms-webhooks/) WooCommerce WooCommerce Order Webhook with Claude Code Ask Claude Code to set up a WooCommerce order webhook — it figures out the right hooks and configures delivery end-to-end. [Read →](https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"IvyForms Webhook Plugin: Send Forms to n8n with AI","description":"IvyForms webhook plugin setup: the built-in IvyForms integration plus Build with AI sends form submissions to n8n with field mapping and automatic retry.","datePublished":"2026-03-28","dateModified":"2026-07-17","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org","sameAs":["https://flowsystems.pl"]},"url":"https://wpwebhooks.org/examples/ivyforms-to-webhook/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/examples/ivyforms-to-webhook/og_image.jpg"},"keywords":["ivyforms webhooks","ivyforms webhook","ivyforms webhook plugin","ivyforms n8n","ivyforms to webhook","ivyforms form submission","wordpress ivyforms webhook","build with ai"]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Can AI set up my IvyForms webhook?","acceptedAnswer":{"@type":"Answer","text":"Yes. The free Webhook Actions by Flow Systems plugin includes Build with AI — describe the integration in plain language (\"When an IvyForms form is submitted, send it as JSON to my n8n webhook\"), and the agent selects the plugin's built-in IvyForms trigger, proposes a plan you can edit, then creates the webhook with endpoint and field mapping configured. New webhooks are created disabled until you confirm."}},{"@type":"Question","name":"Does IvyForms support webhooks natively?","acceptedAnswer":{"@type":"Answer","text":"Not natively. IvyForms doesn't ship with a built-in webhook feature. To send form data to a webhook endpoint you need custom code using the ivyforms/form/after_submission action hook, or a plugin that handles webhook dispatch for you — Webhook Actions by Flow Systems includes a built-in IvyForms integration."}},{"@type":"Question","name":"How do I add a webhook to IvyForms?","acceptedAnswer":{"@type":"Answer","text":"The fastest way is the Build with AI screen in the Webhook Actions by Flow Systems plugin: describe the integration in one sentence and the agent builds it on the plugin's built-in IvyForms integration. Manually, you create a webhook in the plugin admin, select ivyforms/form/after_submission as the trigger, and paste your endpoint URL. Either way you get queued delivery with automatic retries and logging."}},{"@type":"Question","name":"What happens if my n8n webhook is down when IvyForms submits?","acceptedAnswer":{"@type":"Answer","text":"With a bare wp_remote_post() call, the data is lost silently — there's no retry and no log entry. With a queue-based system, the failed delivery is stored and retried automatically with exponential backoff (1 min, 2 min, 4 min, 8 min — 5 attempts). You can also replay any failed submission manually from the WordPress admin."}},{"@type":"Question","name":"Can I retry failed IvyForms webhook deliveries?","acceptedAnswer":{"@type":"Answer","text":"Yes, if you use a plugin with retry support. Webhook Actions by Flow Systems retries failed deliveries automatically and also exposes a REST API endpoint (POST /wp-json/fswa/v1/logs/{id}/retry) that lets you replay any past submission — including successful ones."}}]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Examples","item":"https://wpwebhooks.org/examples/"},{"@type":"ListItem","position":3,"name":"IvyForms Webhook Plugin: Send Forms to n8n with AI","item":"https://wpwebhooks.org/examples/ivyforms-to-webhook/"}]} {"@context":"https://schema.org","@type":"HowTo","name":"How to Send IvyForms Submissions to n8n with Build with AI","description":"Set up a reliable IvyForms to n8n webhook by describing the integration to the Build with AI agent in the Webhook Actions plugin.","step":[{"@type":"HowToStep","name":"Open Build with AI","text":"Install the free Webhook Actions plugin. The Webhook Actions admin menu opens directly on the Build with AI screen."},{"@type":"HowToStep","name":"Describe the integration","text":"Type: When an IvyForms form is submitted, send it as JSON to my n8n webhook — and paste your n8n webhook URL."},{"@type":"HowToStep","name":"Review the plan","text":"The agent selects the plugin's built-in IvyForms trigger (ivyforms/form/after_submission) and proposes an ordered plan you can edit before it runs."},{"@type":"HowToStep","name":"Let it build","text":"The agent creates the webhook (disabled by default), applies field mapping from the captured payload, and probes the endpoint."},{"@type":"HowToStep","name":"Enable and test","text":"Confirm the enable step, submit your IvyForms form, and check the Event Log for the delivery status. Failed deliveries retry automatically and can be replayed."}]} ``` --- --- title: "WooCommerce HubSpot Integration: Sync Order Line Items to Deals" description: "Chain-trigger every WooCommerce order to a HubSpot line_items batch with inline deal associations — full graph in a single outbound POST, every hop logged and replayable." url: "https://wpwebhooks.org/examples/woocommerce-order-line-items-hubspot/" date: "2026-05-15" --- [WP Webhooks](https://wpwebhooks.org/) / [Examples](https://wpwebhooks.org/examples/)/ [HubSpot × WooCommerce](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) / Line Items # WooCommerce HubSpot Integration: Sync Order Line Items to Deals Builds on Parts 1 + 2. When the deal-create webhook returns `201`, Webhook Actions's **Webhook Chains** feature fires one more webhook that POSTs every line item to HubSpot's `/line_items/batch/create` endpoint — with the deal↔line\_item associations attached inline on each input, so the whole graph lands in a single HubSpot call. Each hop is a real webhook with its own log, retry, conditions, and replay — no `wp_remote_post` hidden inside post-glue. **~14 min read** May 15, 2026 Part **3** of 5 Filter OK fswa\_webhook\_payloadfswa\_glue\_post\_dispatch Pro hubspotwoocommerceline-itemsassociations TL;DR — What you'll build - **Webhook 1 — HS - Create Deal (from Part 1):** stays unchanged. On `woocommerce_checkout_order_created` it POSTs the deal, post-glue persists `_hubspot_deal_id`. - **Webhook 2 — HS - Batch Line Items (NEW, chain target):** fires on the deal-create's 2xx response. Pre-glue reshapes `args[0]` (deal id + WC line\_items + product meta) into HubSpot's batch body where each input carries its own `associations` array pointing at the deal — one POST to `/crm/objects/2026-03/line_items/batch/create` creates the line items AND wires them to the deal. Post-glue stores the returned line\_item IDs in WC order meta. - **One chain link, full graph.** A 20-item cart is one outbound batch instead of 40 sub-calls. Inline `associations` on the batch create endpoint removes the need for a separate `/v4/associations/.../batch/create` hop. - **Resolve `hs_product_id`** from WC product meta written by Part 2 inside the batch pre-glue — fall back to free-text line items for products not yet synced. FIG 01 — Order → deal → line-items chain / Prerequisites ## What You Need **Before Starting** ⚡ Part 3 is the first example in the series that _requires_ the previous two. The post-glue assumes (a) a HubSpot deal ID has been written by Part 1's create flow and (b) WC products carry `_hubspot_product_id` from Part 2. Run them in order — or use the Backfill section below to retrofit Part 3 onto existing orders. 1. 1 **Part 1 complete: deal-create webhook + post-glue persisting `_hubspot_deal_id`** Verify by inspecting a recent test order's post meta — `_hubspot_deal_id` should hold a HubSpot record ID. 2. 2 **Part 2 complete: product sync + post-glue persisting `_hubspot_product_id`** Verify by inspecting a recent product's post meta. If you skipped Part 2, line items will still post but without `hs_product_id` association. 3. 3 **HubSpot scopes added: `crm.objects.line_items.write` + `crm.objects.line_items.read`** Edit the same Private App from Parts 1+2. After saving, copy the new token if HubSpot regenerated it. 4. 4 **Shell env from previous parts** Re-export if needed. re-export if needed ``` export SITE="https://your-wordpress-site.com" export TOKEN="fswa_full_..." export HS_PAT="pat-eu1-xxxxxxxx..." ``` / Architecture ## One WC Event, **One Chain Link** Before Webhook Actions 1.13.0 this article shipped with a post-glue loop that called `wp_remote_post` N times per order — functional, but every sub-call was invisible to the Webhook Actions log, with no retries, no conditions, no replay button per item. Webhook Chains replace that pattern: a webhook completing successfully (2xx) becomes the trigger for the next webhook in the chain, which receives the upstream response, the payload sent, and the pre-mapping original payload as its starting `args[0]`. For line items we collapse the loop into a single **batch** call. HubSpot's `/crm/objects/2026-03/line_items/batch/create` takes up to 100 inputs per request, and each input can carry an `associations` array that wires the new line\_item to existing records (the deal we just created) at creation time. Two chained webhooks, two log rows, full graph in HubSpot — even a 20-item cart is one outbound deal POST + one outbound line-items batch. flow diagram ``` # PART 1 (unchanged): create the deal WooCommerce order placed └── fires woocommerce_checkout_order_created └── Webhook #31 POST /crm/objects/2026-03/deals → 201 deal_id=502475571425 └── post-glue (Part 1): persist _hubspot_deal_id to WC order meta └── 2xx → fswa_glue_post_dispatch → ChainDispatcher fires next link # PART 3 (NEW): one batch webhook, chain-triggered, with inline associations └── Webhook #53 HS - Batch Line Items (chain target, trigger fswa_chain_link:N) └── pre-glue: build {inputs:[{properties, associations:[deal]}, ...]} └── POST /crm/objects/2026-03/line_items/batch/create → 201 results[3 ids, all associated to the deal] └── post-glue: persist _hubspot_line_item_ids to WC order meta └── done. Full graph in HubSpot. ``` **Why this beats the old loop:** filter Logs by _HS - Order Line Items_ chain and you see all rows for a single order, in order, with their own retry/replay buttons. If the association batch fails because HubSpot rate-limited mid-burst, you replay one row — not the entire deal-create flow with its double-line-item risk. / Step 1 Filter OK fswa\_webhook\_payloadfswa\_glue\_post\_dispatch Pro ## Webhook 2 — **HS - Batch Line Items** Create the first chain target: a webhook with no WP-hook triggers, configured for HubSpot's line\_items batch endpoint. It will be wired as the chain target of Webhook 31 (_HS - Create Deal_) from Part 1, so it fires automatically on every deal-create success. ### 1.1 — Create the webhook via REST First, store the HubSpot PAT once in the plugin's **Credentials Vault** — webhooks then reference it by id via `auth_credential_id`, which takes precedence over the legacy plaintext `auth_header`. The secret is encrypted at rest and write-only over the API: it never shows up in webhook configs, logs, or agent responses. one-time — store the PAT in the Credentials Vault ``` CRED_ID=$(curl -sk -X POST "$SITE/wp-json/fswa/v1/credentials" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "HubSpot PAT", "type": "bearer", "secret": "'"$HS_PAT"'"}' | jq -r '.id') ``` create webhook 53 — no WP triggers (chain-only) ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Batch Line Items", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/line_items/batch/create", "http_method": "POST", "auth_credential_id": '"$CRED_ID"', "is_enabled": true, "is_synchronous": false, "retry_limit": 2, "triggers": [] }' ``` response — 200 OK ``` { "id": "53", "webhook_uuid": "b41fc8...", "name": "HS - Batch Line Items", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/line_items/batch/create", "triggers": [] } ``` Empty `triggers: []` is allowed in v1.13.0+ — the webhook will be triggered exclusively by the chain link we add in step 1.4. Without a chain link this webhook is dormant. ### 1.2 — Pre-dispatch Code Glue: build the batch inputs The chain target receives the upstream deal-create response and the original WC payload as `args[0]`. The pre-glue's job is to reshape that into HubSpot's batch body: `{ inputs: [{properties: {...}}, ...] }`. **Payload shaping only — no outbound HTTP, no DB writes here.** snippet code — pre-dispatch glue ``` // Read chain args. $payload is the chain payload built by ChainDispatcher. $chain = $payload["args"][0] ?? []; $dealId = (string) ($chain["response"]["body"]["id"] ?? ""); // Recover the WC order id. Resolution order: // 1) _wc_order_id if Part 1's pre-glue stashed it in the sent payload // 2) payload.args[0] — woocommerce_checkout_order_created passes the order_id scalar // 3) original_payload.args[0] — same data, set when mapping is applied $wcOrderId = (int) ( $chain["payload"]["_wc_order_id"] ?? $chain["payload"]["args"][0] ?? $chain["original_payload"]["args"][0] ?? 0 ); if (!$dealId || !$wcOrderId) return ["inputs" => []]; $order = wc_get_order($wcOrderId); if (!$order) return ["inputs" => []]; $inputs = []; $wcItemIds = []; // parallel array so post-glue can map result[i] back to a WC order item id foreach ($order->get_items() as $itemId => $item) { $wcProductId = $item->get_variation_id() ?: $item->get_product_id(); $hsProductId = (string) get_post_meta($wcProductId, "_hubspot_product_id", true); $props = [ "name" => $item->get_name(), "quantity" => (string) $item->get_quantity(), "price" => (string) ($item->get_total() / max(1, $item->get_quantity())), "amount" => (string) $item->get_total(), ]; if ($hsProductId) { $props["hs_product_id"] = $hsProductId; } // Inline association: line_item → deal (HUBSPOT_DEFINED type id 20). // HubSpot creates the line item AND wires it to the deal in this same batch call. $inputs[] = [ "properties" => $props, "associations" => [[ "to" => ["id" => $dealId], "types" => [[ "associationCategory" => "HUBSPOT_DEFINED", "associationTypeId" => 20, // line_item_to_deal ]], ]], ]; $wcItemIds[] = (int) $itemId; } // Side-channel: HubSpot ignores unknown top-level fields on this endpoint. // Post-glue reads these to map result[i].id back to the corresponding WC order item. return [ "inputs" => $inputs, "_wc_order_id" => $wcOrderId, "_wc_item_ids" => $wcItemIds, ]; ``` **HubSpot only reads `inputs` from the body** and ignores extra top-level fields like `_wc_order_id` / `_wc_item_ids`. We use those fields as a side-channel for the post-glue — they show up in the Webhook Actions log's sent-payload column but never reach HubSpot's parser. If your downstream API is strict, use Webhook Actions's _excluded paths_ field-mapping option to drop them before send. ### 1.3 — Post-dispatch Code Glue: persist the line\_item ids snippet code — post-dispatch glue ``` $body = json_decode($responseBody, true); $results = $body["results"] ?? []; // Side-channel data stashed by pre-glue. $orderId = (int) ($payload["_wc_order_id"] ?? 0); $wcItemIds = (array) ($payload["_wc_item_ids"] ?? []); if ($responseCode < 200 || $responseCode >= 300 || !$orderId) return; $order = wc_get_order($orderId); if (!$order) return; $liIds = []; // HubSpot returns results in the same order we sent inputs — we can zip by index. foreach ($results as $idx => $r) { $liId = (string) ($r["id"] ?? ""); if (!$liId) continue; $liIds[] = $liId; // Per-WC-order-item meta — survives refunds + per-line edits + downstream sync. $wcItemId = (int) ($wcItemIds[$idx] ?? 0); if ($wcItemId) { wc_update_order_item_meta($wcItemId, "_hubspot_line_item_id", $liId); } } // Order-level rollup for quick lookups + replay guards. $order->update_meta_data("_hubspot_line_item_ids", $liIds); $order->save(); ``` **Per-order-item meta vs order meta.** Storing the HubSpot id on each WC order item (`wc_update_order_item_meta`, written to `wp_woocommerce_order_itemmeta`) makes per-line operations cheap later: refund a single item and sync just that refund to HubSpot; edit a line quantity and PATCH only the matching `hs_line_item`; report mismatches per row. The order-level `_hubspot_line_item_ids` array is the fast rollup for "does this order already have line items synced?" replay guards. ### 1.4 — Attach both snippets, then wire as chain target Save the two snippets in the Webhook Actions snippet library, attach them to webhook 53 (no trigger needed — chains use the synthetic `fswa_chain_link:N` trigger which gets bound when you link the webhooks). Then in the admin: edit webhook 53, toggle _"Use other Webhooks as triggers"_, pick the existing chain (or create one named _"HS - Order Line Items"_) and select **Webhook 31 (HS - Create Deal)** as the source. Or wire it via REST: create the chain (if it doesn't exist) ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/chains" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "HS - Order Line Items", "description": "Deal create → batch line items → batch associations"}' # → {"id":"10","name":"HS - Order Line Items",...} ``` link webhook 31 → webhook 53 ``` curl -sk -X POST "$SITE/wp-json/fswa/v1/chains/10/links" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"source_webhook_id": 31, "target_webhook_id": 53}' ``` **Why `is_synchronous: false`?** The deal-create webhook stays synchronous (Part 1 needs the response inline). Chain targets fire on the queue by default — if HubSpot rate-limits the line\_items batch or returns a 5xx, retries happen in the background without blocking the WC checkout response. Customer hits the thank-you page in milliseconds; line items land in HubSpot within seconds. **When to flip to sync.** Set `is_synchronous: true` on this chain target when (a) you have a post-checkout thank-you page that reads HubSpot data directly, (b) an internal CRM-driven email / Slack notification fires next and needs the full graph immediately, or (c) the store is internal / B2B where checkout latency doesn't matter. Cost: ~150–400ms HubSpot round-trip added to the checkout response, plus checkout blocks if HubSpot has an outage. For most public storefronts, async is the safer default. / Step 2 ## End-to-End Test — **Three-Line Order** Place a test order with three different products in the cart. The deal-create webhook (Part 1) fires once and returns the deal ID. On its 2xx response the chain dispatcher fires the line-items batch webhook, which POSTs all three line items + their deal associations in a single call. Open HubSpot → CRM → Deals → (your test deal). Scroll to the **Line items** section — you should see all three products listed with their names, quantities, and total amounts. Each `hs_product_id` link should resolve to the matching HubSpot product from Part 2. HubSpot → Deal detail → Line items card showing all three products from the WooCommerce order with quantities and amounts, each associated to the deal inline via the batch/create endpoint. **Verify in Webhook Actions.** Filter the Logs view by chain _HS - Order Line Items_: you should see exactly two rows for the test order — one `woocommerce_checkout_order_created` dispatch (the deal create, status `success`, 201) and one `fswa_chain_link:N` dispatch (the line-items batch, status `success`, 201) sharing the same event UUID. **Verify in WC.** Inspect the order's meta — `_hubspot_deal_id` holds the deal ID, `_hubspot_line_item_ids` holds the array of HubSpot line\_item record IDs. Each WC order item also carries an individual `_hubspot_line_item_id` in `wp_woocommerce_order_itemmeta`: wp-cli inspection ``` wp wc shell <<'PHP' $order = wc_get_order($ORDER_ID); echo "deal_id: " . $order->get_meta("_hubspot_deal_id") . PHP_EOL; echo "line_item_ids: " . json_encode($order->get_meta("_hubspot_line_item_ids")) . PHP_EOL; foreach ($order->get_items() as $itemId => $item) { echo $itemId . " → " . wc_get_order_item_meta($itemId, "_hubspot_line_item_id") . PHP_EOL; } PHP # deal_id: 502475571425 # line_item_ids: ["18923771401","18923771402","18923771403"] # 9201 → 18923771401 # 9202 → 18923771402 # 9203 → 18923771403 ``` Webhook Actions admin → Logs filtered by chain _HS - Order Line Items_. Deal-create row (201) and the line-items batch chain-link row (201) for the same order, with the humanized _Chain HS - Order Line Items ← HS - Create Deal_ pill on the chain-link row. / Backfill ## Retrofit **Existing Orders** If you already have orders with HubSpot deals but no line items, run a one-off backfill that calls the same post-glue logic for each existing order: wp-cli backfill ``` wp eval ' $orders = wc_get_orders(["limit" => -1, "status" => ["processing","completed"], "return" => "ids"]); foreach ($orders as $oid) { $dealId = get_post_meta($oid, "_hubspot_deal_id", true); $existingLi = get_post_meta($oid, "_hubspot_line_item_ids", true); if (!$dealId || $existingLi) continue; // skip orders without deal or already done // Simulate the post-glue context: payload, responseCode, responseBody do_action("fswa_glue_backfill_line_items", $oid, $dealId); echo "backfilled order $oid (deal $dealId) "; }' ``` Bind `fswa_glue_backfill_line_items` in your mu-plugin to the same code as the Part 3 block above (extracted to a function). This keeps the line-item loop testable in isolation and reusable from both the live post-glue and the WP-CLI backfill. / Common Pitfalls ## Things **That Bite** **100 inputs per batch limit.** HubSpot's `/line_items/batch/create` accepts up to 100 inputs per request. A single cart over 100 items would need chunking — rare for B2C but possible for B2B order forms. The pre-glue can chunk using `array_chunk` — but then the batch endpoint only takes one chunk per dispatch, so keep the cart-size assumption documented and add a fallback warning if `count($inputs) > 100`. **Missing `hs_product_id`.** If a product was created in WC before Part 2 ran, no `_hubspot_product_id` meta exists. The pre-glue falls back to creating a free-text line item — visible in HubSpot but with no SKU link. Run the Part 2 backfill first to fix it across the catalogue. **Association type id 20 (line\_item\_to\_deal) vs 19 (deal\_to\_line\_item).** The direction matters in v4 associations — we create the line\_item, so the edge is FROM line\_item TO deal, which is HUBSPOT\_DEFINED type `20`. Reading associations on the deal returns type `19` (the inverse). Mixing them gets you a 400 from HubSpot. Look up the canonical id at `/crm/v4/associations/line_items/deals/labels`. **Currency precision.** Some HubSpot line\_item reports round to two decimals while WC stores totals at higher precision. The pre-glue casts to string before sending; HubSpot accepts string numerics for currency properties. **Replay doubles line items.** The chain target webhook is _not_ idempotent by default. Replaying the deal-create from the Webhook Actions log re-fires the chain and creates a second set of line items. Two guard strategies: (a) add a condition on the chain link "skip if order already has `_hubspot_line_item_ids`", or (b) add a check at the top of the pre-glue: `if ($order->get_meta("_hubspot_line_item_ids")) return ["inputs" => []];` — an empty inputs array is a no-op POST that HubSpot accepts and the chain still completes cleanly. / HubSpot × WooCommerce Series [← Previous Part 2 — Product Sync](https://wpwebhooks.org/examples/hubspot-product-sync-woocommerce/) [All 5 parts Series Index](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) [Next → Part 4 — Customer → Contact](https://wpwebhooks.org/examples/add-customer-to-hubspot-on-woocommerce-order/) /Notes → [HubSpot Line Items — create-line-item (2026-03)](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/line-items/create-line-item) → [HubSpot Line Items — update-line-item (2026-03)](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/line-items/update-line-item) → [HubSpot Line Items — get-line-items (2026-03)](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/line-items/get-line-items) → [HubSpot Associations API](https://developers.hubspot.com/docs/api-reference/latest/crm/associations/overview) → [All WordPress webhook automation examples](https://wpwebhooks.org/examples/) /FAQ ## Common questions always ask. Don't see yours? Open an issue on GitHub or check the full reference in the API docs. Why a chain webhook instead of a post-dispatch loop? + Before Webhook Actions 1.13.0 this article used a post-glue loop calling wp\_remote\_post for each line item — functional but every sub-call was invisible to the Webhook Actions log, with no per-item retry or replay. Webhook Chains (v1.13.0+) make each downstream call a real webhook: its own log row, its own retry policy, its own replay button. Filter logs by chain to see all hops for one order side by side. What if a WC product wasn't synced to HubSpot first (Part 2)? + The pre-glue checks for \_hubspot\_product\_id; if missing, it falls back to creating a free-text line item with just name + price + quantity (no hs\_product\_id association). You lose SKU-level reporting but the deal still has line items attached. Run the Part 2 backfill to retrofit. Does this need Pro? + Chains themselves are free in Webhook Actions v1.13.0+. Pro makes the pre-glue and post-glue payload shaping easy: snippets live in the admin and attach per webhook+trigger. The free-plugin alternative is to hook fswa\_webhook\_payload (pre-dispatch shape) and fswa\_glue\_post\_dispatch (post-dispatch persist) in your own mu-plugin — same hooks under the hood. Why inline associations on batch create instead of a second association webhook? + HubSpot's /crm/objects/2026-03/line\_items/batch/create accepts an associations array per input that wires the new line\_item to existing records at creation time. That collapses what used to be two HubSpot round-trips into one. For line\_item to deal we use HUBSPOT\_DEFINED type id 20 (line\_item\_to\_deal direction). Will replaying the chain double the line items? + Yes by default. Guards: (a) attach a per-trigger condition to the synthetic fswa\_chain\_link:N trigger saying 'skip if order has \_hubspot\_line\_item\_ids', or (b) add a pre-glue idempotency check that returns inputs:\[\] when the order is already synced. An empty-inputs POST is a no-op HubSpot accepts gracefully. /More examples ## Related integrations. Part 1 Create & Update Deals from WC Orders The deal-create webhook this part extends with the line-items post-glue. [Read →](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) Part 2 WooCommerce Product Sync to HubSpot Stores \_hubspot\_product\_id on each WC product, which Part 3 reads for line-item associations. [Read →](https://wpwebhooks.org/examples/hubspot-product-sync-woocommerce/) Series Hub HubSpot × WooCommerce — All 5 Parts The complete integration series — deals, products, line items, customers, and the full graph. [Read →](https://wpwebhooks.org/examples/hubspot-woocommerce-integration/) ## Structured data ```json {"@context":"https://schema.org","@type":"Article","headline":"WooCommerce HubSpot Integration: Sync Order Line Items to Deals","description":"Chain-trigger every WC order to a HubSpot line_items batch with inline deal associations — full graph in a single outbound POST, every hop logged and replayable.","datePublished":"2026-05-15","dateModified":"2026-05-19","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org","sameAs":["https://flowsystems.pl"]},"url":"https://wpwebhooks.org/examples/woocommerce-order-line-items-hubspot/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/og_image.jpg"},"keywords":["woocommerce hubspot integration","hubspot line items woocommerce","woocommerce order line items hubspot","hubspot wordpress plugin"],"isPartOf":{"@type":"CreativeWorkSeries","name":"HubSpot × WooCommerce Integration Series","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"}} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why a chain webhook instead of a post-dispatch loop?","acceptedAnswer":{"@type":"Answer","text":"Before Webhook Actions 1.13.0 this article used a post-glue loop calling wp_remote_post for each line item — functional but every sub-call was invisible to the Webhook Actions log, with no per-item retry or replay. Webhook Chains (v1.13.0+) make each downstream call a real webhook: its own log row, its own retry policy, its own replay button. Filter logs by chain to see all hops for one order side by side."}},{"@type":"Question","name":"What if a WC product wasn't synced to HubSpot first (Part 2)?","acceptedAnswer":{"@type":"Answer","text":"The pre-glue checks for _hubspot_product_id; if missing, it falls back to creating a free-text line item with just name + price + quantity (no hs_product_id association). You lose SKU-level reporting but the deal still has line items attached. Run the Part 2 backfill to retrofit."}},{"@type":"Question","name":"Does this need Pro?","acceptedAnswer":{"@type":"Answer","text":"Chains themselves are free in Webhook Actions v1.13.0+. Pro makes the pre-glue and post-glue payload shaping easy: snippets live in the admin and attach per webhook+trigger. The free-plugin alternative is to hook fswa_webhook_payload (pre-dispatch shape) and fswa_glue_post_dispatch (post-dispatch persist) in your own mu-plugin — same hooks under the hood."}},{"@type":"Question","name":"Why inline associations on batch create instead of a second association webhook?","acceptedAnswer":{"@type":"Answer","text":"HubSpot's /crm/objects/2026-03/line_items/batch/create accepts an associations array per input that wires the new line_item to existing records at creation time. That collapses what used to be two HubSpot round-trips into one. For line_item to deal we use HUBSPOT_DEFINED type id 20 (line_item_to_deal direction)."}},{"@type":"Question","name":"Will replaying the chain double the line items?","acceptedAnswer":{"@type":"Answer","text":"Yes by default. Guards: (a) attach a per-trigger condition to the synthetic fswa_chain_link:N trigger saying 'skip if order has _hubspot_line_item_ids', or (b) add a pre-glue idempotency check that returns inputs:[] when the order is already synced. An empty-inputs POST is a no-op HubSpot accepts gracefully."}}]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Examples","item":"https://wpwebhooks.org/examples/"},{"@type":"ListItem","position":3,"name":"HubSpot × WooCommerce","item":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"},{"@type":"ListItem","position":4,"name":"Line Items","item":"https://wpwebhooks.org/examples/woocommerce-order-line-items-hubspot/"}]} {"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/hubspot-line-items-chain.png","caption":"FIG 01 — Order → deal → line-items chain","description":"A WooCommerce checkout fires the first webhook, which creates a HubSpot deal and stores its id. On the deal-create 2xx response a Webhook Chain fires a second webhook whose pre-glue reshapes the order into a single HubSpot line_items batch-create call with inline deal associations, then post-glue stores the returned line_item ids in the order meta.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"} ``` --- --- title: "WordPress Webhook Automation Examples" description: "Practical step-by-step examples showing how to build reliable WordPress webhook automations with retry, logging, and replay support. CF7, Gravity Forms, WooCommerce, HubSpot, and more." url: "https://wpwebhooks.org/examples/" --- [WP Webhooks](https://wpwebhooks.org/) / Examples # WordPress Webhook Automation Examples Practical step-by-step examples showing how to build reliable WordPress webhook automations — with retry, logging, and replay support built in. / Forms ## Form submissions to webhooks. Contact Form 7 CF7 to Webhook: Plugin Setup with AI CF7 doesn't support webhooks natively. Describe the integration to Build with AI in one sentence — trigger, field mapping, and retries wired to n8n. ~6 min [Read →](https://wpwebhooks.org/examples/cf7-to-webhook/) IvyForms IvyForms Webhook Plugin: Send Forms to n8n with AI IvyForms doesn't support webhooks natively. The plugin's built-in IvyForms integration plus Build with AI forwards submissions to n8n reliably. ~5 min [Read →](https://wpwebhooks.org/examples/ivyforms-to-webhook/) Gravity Forms Gravity Forms Webhook Plugin: Build It with AI No Webhooks Add-On or Elite license — Build with AI wires gform\_after\_submission to any endpoint with field mapping, retries, and a full event log. ~6 min [Read →](https://wpwebhooks.org/examples/gravity-forms-webhooks/) / WooCommerce ## Order events wired up. WooCommerce + Claude Code Ask Claude Code to Set Up a WooCommerce Order Webhook You describe what you want. Claude Code hits the REST API, reads the live payload structure, sets a condition — and catches its own field-path mistake before it ships. ~8 min [Read →](https://wpwebhooks.org/examples/woocommerce-order-webhook-claude-code/) / HubSpot × WooCommerce ## 5-part series: deals, products, line items and contacts. Start from Part 1 or jump to any part — each page is self-contained with all curl commands and code snippets. Part 1 of 5 Create & Update Deals from WooCommerce Orders Build the canonical HubSpot WooCommerce integration: create a HubSpot deal when a WC order is placed, move it to Closed Won when the order completes. Uses the new 2026-03 HubSpot endpoint and Code Glue. ~12 min [Read →](https://wpwebhooks.org/examples/hubspot-create-deal-woocommerce/) Part 2 of 5 Sync WooCommerce Products to HubSpot Push WooCommerce products to HubSpot on publish (POST), keep them in sync on update (PATCH). The two-webhook condition split that keeps repeat edits from duplicating. ~10 min [Read →](https://wpwebhooks.org/examples/hubspot-product-sync-woocommerce/) Part 3 of 5 Order Line Items in HubSpot When a deal is created (Part 1), iterate WC line\_items, POST each as a HubSpot line\_item with its hs\_product\_id (from Part 2), and associate to the deal. ~10 min [Read →](https://wpwebhooks.org/examples/woocommerce-order-line-items-hubspot/) Part 4 of 5 Sync Contact on User Register Mirror every WordPress user registration into HubSpot CRM as a Contact — idempotent by email via the 2026-03 batch/upsert endpoint. Optional companion to Part 5: pre-populates user\_meta so the order chain hits the cheap PUT path on day one. ~10 min [Read →](https://wpwebhooks.org/examples/add-customer-to-hubspot-on-woocommerce-order/) Part 5 of 5 Full Customer × Order Sync Tie it all together with a dual-path chain. The deal-create fans out: existing customer → PUT contact↔deal association; new customer → POST upsert contact, then PUT association. Every WordPress order becomes a complete HubSpot graph. ~18 min [Read →](https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/) / Coming soon Coming soon WordPress → Slack Notifications Coming soon WPForms → n8n Automation Coming soon WooCommerce Abandoned Cart → CRM Coming soon Elementor Forms → Webhook /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"CollectionPage","name":"WordPress Webhook Automation Examples","description":"Practical step-by-step examples showing how to build reliable WordPress webhook automations with retry, logging, and replay support.","url":"https://wpwebhooks.org/examples/","publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"}} ``` --- --- title: "Contact Form 7 to Webhook — End-to-End Walkthrough — Screencast" description: "A 15-second end-to-end screencast following a Contact Form 7 submission through the full webhook delivery pipeline: CF7 form submitted in the browser, wpcf7_mai" url: "https://wpwebhooks.org/videos/contact-form-7-webhook-walkthrough/" --- [WP Webhooks](https://wpwebhooks.org/) / [Videos](https://wpwebhooks.org/videos/) / Contact Form 7 to Webhook — End-to-End Walkthrough 15s · CF7 Integration # Contact Form 7 to Webhook — End-to-End Walkthrough CF7 submit → Webhook Actions → n8n payload A 15-second end-to-end screencast following a Contact Form 7 submission through the full webhook delivery pipeline: CF7 form submitted in the browser, wpcf7\_mail\_sent hook fires, Webhook Actions dispatches the payload to an n8n Webhook trigger node, and the Event Log confirms HTTP 200 delivery. / Related docs [Contact Form 7 Integration](https://wpwebhooks.org/docs/cf7-integration/)[Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/) / More screencasts ![WordPress Webhook Smart Retry in Action thumbnail](https://wpwebhooks.org/videos/thumbnails/smart_retry_and_reply.webp) 50s · Retry Logic [WordPress Webhook Smart Retry in Action](https://wpwebhooks.org/videos/wordpress-webhook-smart-retry/) ![WordPress Webhook Payload Mapping via Plugin UI thumbnail](https://wpwebhooks.org/videos/thumbnails/payload_mapping_via_UI.webp) 58s · Payload Mapping [WordPress Webhook Payload Mapping via Plugin UI](https://wpwebhooks.org/videos/wordpress-webhook-payload-mapping/) ![Gravity Forms to Webhook — Full Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_to_webhook.webp) 22s · Gravity Forms [Gravity Forms to Webhook — Full Walkthrough](https://wpwebhooks.org/videos/gravity-forms-webhook-full-walkthrough/) ![Create a Gravity Forms Webhook in WordPress thumbnail](https://wpwebhooks.org/videos/thumbnails/create_gform_webhook.webp) 20s · Gravity Forms Setup [Create a Gravity Forms Webhook in WordPress](https://wpwebhooks.org/videos/gravity-forms-create-webhook/) ![Gravity Forms Webhook Submission — Event Log & Payload thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_submitted.webp) 20s · Event Log [Gravity Forms Webhook Submission — Event Log & Payload](https://wpwebhooks.org/videos/gravity-forms-webhook-submission/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"VideoObject","name":"Contact Form 7 to Webhook — End-to-End Walkthrough","description":"A 15-second end-to-end screencast following a Contact Form 7 submission through the full webhook delivery pipeline: CF7 form submitted in the browser, wpcf7_mail_sent hook fires, Webhook Actions dispatches the payload to an n8n Webhook trigger node, and the Event Log confirms HTTP 200 delivery.","thumbnailUrl":"https://wpwebhooks.org/videos/thumbnails/cf7_to_webhook.webp","contentUrl":"https://wpwebhooks.org/videos/cf7_to_webhook.mp4","duration":"PT15S","uploadDate":"2026-04-07T00:00:00+00:00","publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"}} ``` --- --- title: "Create a Gravity Forms Webhook in WordPress — Screencast" description: "A 20-second screencast showing how to create a new webhook in the Webhook Actions plugin targeting a Gravity Forms submission — adding the endpoint URL, selecti" url: "https://wpwebhooks.org/videos/gravity-forms-create-webhook/" --- [WP Webhooks](https://wpwebhooks.org/) / [Videos](https://wpwebhooks.org/videos/) / Create a Gravity Forms Webhook in WordPress 20s · Gravity Forms Setup # Create a Gravity Forms Webhook in WordPress Add endpoint URL, select trigger, filter by form ID A 20-second screencast showing how to create a new webhook in the Webhook Actions plugin targeting a Gravity Forms submission — adding the endpoint URL, selecting gform\_after\_submission as the trigger action, and optionally filtering by form ID to target a specific form. / Related docs [Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/) / More screencasts ![WordPress Webhook Smart Retry in Action thumbnail](https://wpwebhooks.org/videos/thumbnails/smart_retry_and_reply.webp) 50s · Retry Logic [WordPress Webhook Smart Retry in Action](https://wpwebhooks.org/videos/wordpress-webhook-smart-retry/) ![WordPress Webhook Payload Mapping via Plugin UI thumbnail](https://wpwebhooks.org/videos/thumbnails/payload_mapping_via_UI.webp) 58s · Payload Mapping [WordPress Webhook Payload Mapping via Plugin UI](https://wpwebhooks.org/videos/wordpress-webhook-payload-mapping/) ![Contact Form 7 to Webhook — End-to-End Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/cf7_to_webhook.webp) 15s · CF7 Integration [Contact Form 7 to Webhook — End-to-End Walkthrough](https://wpwebhooks.org/videos/contact-form-7-webhook-walkthrough/) ![Gravity Forms to Webhook — Full Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_to_webhook.webp) 22s · Gravity Forms [Gravity Forms to Webhook — Full Walkthrough](https://wpwebhooks.org/videos/gravity-forms-webhook-full-walkthrough/) ![Gravity Forms Webhook Submission — Event Log & Payload thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_submitted.webp) 20s · Event Log [Gravity Forms Webhook Submission — Event Log & Payload](https://wpwebhooks.org/videos/gravity-forms-webhook-submission/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"VideoObject","name":"Create a Gravity Forms Webhook in WordPress","description":"A 20-second screencast showing how to create a new webhook in the Webhook Actions plugin targeting a Gravity Forms submission — adding the endpoint URL, selecting gform_after_submission as the trigger action, and optionally filtering by form ID to target a specific form.","thumbnailUrl":"https://wpwebhooks.org/videos/thumbnails/create_gform_webhook.webp","contentUrl":"https://wpwebhooks.org/videos/create_gform_webhook.mp4","duration":"PT20S","uploadDate":"2026-04-07T00:00:00+00:00","publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"}} ``` --- --- title: "Gravity Forms to Webhook — Full Walkthrough — Screencast" description: "A 22-second end-to-end screencast showing the complete Gravity Forms webhook integration: form submitted in the browser, gform_after_submission hook fires, Webh" url: "https://wpwebhooks.org/videos/gravity-forms-webhook-full-walkthrough/" --- [WP Webhooks](https://wpwebhooks.org/) / [Videos](https://wpwebhooks.org/videos/) / Gravity Forms to Webhook — Full Walkthrough 22s · Gravity Forms # Gravity Forms to Webhook — Full Walkthrough Form submit → gform\_after\_submission → n8n delivery A 22-second end-to-end screencast showing the complete Gravity Forms webhook integration: form submitted in the browser, gform\_after\_submission hook fires, Webhook Actions dispatches the payload to n8n, and the Event Log confirms HTTP 200 delivery with the full JSON payload visible in n8n. / Related docs [Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/) / More screencasts ![WordPress Webhook Smart Retry in Action thumbnail](https://wpwebhooks.org/videos/thumbnails/smart_retry_and_reply.webp) 50s · Retry Logic [WordPress Webhook Smart Retry in Action](https://wpwebhooks.org/videos/wordpress-webhook-smart-retry/) ![WordPress Webhook Payload Mapping via Plugin UI thumbnail](https://wpwebhooks.org/videos/thumbnails/payload_mapping_via_UI.webp) 58s · Payload Mapping [WordPress Webhook Payload Mapping via Plugin UI](https://wpwebhooks.org/videos/wordpress-webhook-payload-mapping/) ![Contact Form 7 to Webhook — End-to-End Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/cf7_to_webhook.webp) 15s · CF7 Integration [Contact Form 7 to Webhook — End-to-End Walkthrough](https://wpwebhooks.org/videos/contact-form-7-webhook-walkthrough/) ![Create a Gravity Forms Webhook in WordPress thumbnail](https://wpwebhooks.org/videos/thumbnails/create_gform_webhook.webp) 20s · Gravity Forms Setup [Create a Gravity Forms Webhook in WordPress](https://wpwebhooks.org/videos/gravity-forms-create-webhook/) ![Gravity Forms Webhook Submission — Event Log & Payload thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_submitted.webp) 20s · Event Log [Gravity Forms Webhook Submission — Event Log & Payload](https://wpwebhooks.org/videos/gravity-forms-webhook-submission/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"VideoObject","name":"Gravity Forms to Webhook — Full Walkthrough","description":"A 22-second end-to-end screencast showing the complete Gravity Forms webhook integration: form submitted in the browser, gform_after_submission hook fires, Webhook Actions dispatches the payload to n8n, and the Event Log confirms HTTP 200 delivery with the full JSON payload visible in n8n.","thumbnailUrl":"https://wpwebhooks.org/videos/thumbnails/gform_to_webhook.webp","contentUrl":"https://wpwebhooks.org/videos/gform_to_webhook.mp4","duration":"PT22S","uploadDate":"2026-04-07T00:00:00+00:00","publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"}} ``` --- --- title: "Gravity Forms Webhook Submission — Event Log & Payload — Screencast" description: "A 20-second screencast showing what happens in the WordPress admin after a Gravity Forms submission fires a webhook via Webhook Actions: the Event Log entry app" url: "https://wpwebhooks.org/videos/gravity-forms-webhook-submission/" --- [WP Webhooks](https://wpwebhooks.org/) / [Videos](https://wpwebhooks.org/videos/) / Gravity Forms Webhook Submission — Event Log & Payload 20s · Event Log # Gravity Forms Webhook Submission — Event Log & Payload HTTP status · attempt count · full JSON payload A 20-second screencast showing what happens in the WordPress admin after a Gravity Forms submission fires a webhook via Webhook Actions: the Event Log entry appears with HTTP status code and attempt count, and the log detail view reveals the complete JSON payload delivered to the endpoint. / Related docs [Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/) / More screencasts ![WordPress Webhook Smart Retry in Action thumbnail](https://wpwebhooks.org/videos/thumbnails/smart_retry_and_reply.webp) 50s · Retry Logic [WordPress Webhook Smart Retry in Action](https://wpwebhooks.org/videos/wordpress-webhook-smart-retry/) ![WordPress Webhook Payload Mapping via Plugin UI thumbnail](https://wpwebhooks.org/videos/thumbnails/payload_mapping_via_UI.webp) 58s · Payload Mapping [WordPress Webhook Payload Mapping via Plugin UI](https://wpwebhooks.org/videos/wordpress-webhook-payload-mapping/) ![Contact Form 7 to Webhook — End-to-End Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/cf7_to_webhook.webp) 15s · CF7 Integration [Contact Form 7 to Webhook — End-to-End Walkthrough](https://wpwebhooks.org/videos/contact-form-7-webhook-walkthrough/) ![Gravity Forms to Webhook — Full Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_to_webhook.webp) 22s · Gravity Forms [Gravity Forms to Webhook — Full Walkthrough](https://wpwebhooks.org/videos/gravity-forms-webhook-full-walkthrough/) ![Create a Gravity Forms Webhook in WordPress thumbnail](https://wpwebhooks.org/videos/thumbnails/create_gform_webhook.webp) 20s · Gravity Forms Setup [Create a Gravity Forms Webhook in WordPress](https://wpwebhooks.org/videos/gravity-forms-create-webhook/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"VideoObject","name":"Gravity Forms Webhook Submission — Event Log & Payload","description":"A 20-second screencast showing what happens in the WordPress admin after a Gravity Forms submission fires a webhook via Webhook Actions: the Event Log entry appears with HTTP status code and attempt count, and the log detail view reveals the complete JSON payload delivered to the endpoint.","thumbnailUrl":"https://wpwebhooks.org/videos/thumbnails/gform_submitted.webp","contentUrl":"https://wpwebhooks.org/videos/gform_submitted.mp4","duration":"PT20S","uploadDate":"2026-04-07T00:00:00+00:00","publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"}} ``` --- --- title: "WordPress Webhook Payload Mapping via Plugin UI — Screencast" description: "A 58-second screencast showing how to configure webhook payload mapping in the Webhook Actions plugin UI — selecting which WordPress hook context fields to incl" url: "https://wpwebhooks.org/videos/wordpress-webhook-payload-mapping/" --- [WP Webhooks](https://wpwebhooks.org/) / [Videos](https://wpwebhooks.org/videos/) / WordPress Webhook Payload Mapping via Plugin UI 58s · Payload Mapping # WordPress Webhook Payload Mapping via Plugin UI Select fields, rename keys, save — no PHP required A 58-second screencast showing how to configure webhook payload mapping in the Webhook Actions plugin UI — selecting which WordPress hook context fields to include in the JSON payload, renaming field keys, and saving the mapping configuration — without writing any PHP code. / Related docs [Payload Mapping](https://wpwebhooks.org/docs/payload-mapping/) / More screencasts ![WordPress Webhook Smart Retry in Action thumbnail](https://wpwebhooks.org/videos/thumbnails/smart_retry_and_reply.webp) 50s · Retry Logic [WordPress Webhook Smart Retry in Action](https://wpwebhooks.org/videos/wordpress-webhook-smart-retry/) ![Contact Form 7 to Webhook — End-to-End Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/cf7_to_webhook.webp) 15s · CF7 Integration [Contact Form 7 to Webhook — End-to-End Walkthrough](https://wpwebhooks.org/videos/contact-form-7-webhook-walkthrough/) ![Gravity Forms to Webhook — Full Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_to_webhook.webp) 22s · Gravity Forms [Gravity Forms to Webhook — Full Walkthrough](https://wpwebhooks.org/videos/gravity-forms-webhook-full-walkthrough/) ![Create a Gravity Forms Webhook in WordPress thumbnail](https://wpwebhooks.org/videos/thumbnails/create_gform_webhook.webp) 20s · Gravity Forms Setup [Create a Gravity Forms Webhook in WordPress](https://wpwebhooks.org/videos/gravity-forms-create-webhook/) ![Gravity Forms Webhook Submission — Event Log & Payload thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_submitted.webp) 20s · Event Log [Gravity Forms Webhook Submission — Event Log & Payload](https://wpwebhooks.org/videos/gravity-forms-webhook-submission/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"VideoObject","name":"WordPress Webhook Payload Mapping via Plugin UI","description":"A 58-second screencast showing how to configure webhook payload mapping in the Webhook Actions plugin UI — selecting which WordPress hook context fields to include in the JSON payload, renaming field keys, and saving the mapping configuration — without writing any PHP code.","thumbnailUrl":"https://wpwebhooks.org/videos/thumbnails/payload_mapping_via_UI.webp","contentUrl":"https://wpwebhooks.org/videos/payload_mapping_via_UI.mp4","duration":"PT58S","uploadDate":"2026-04-07T00:00:00+00:00","publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"}} ``` --- --- title: "WordPress Webhook Screencasts & Video Tutorials" description: "Screencasts showing WordPress webhook setup, payload mapping, smart retry, and form integrations with CF7, Gravity Forms, and n8n. Self-hosted, no YouTube required." url: "https://wpwebhooks.org/videos/" --- [WP Webhooks](https://wpwebhooks.org/)/ Videos # Screencasts Short video walkthroughs — webhook setup, payload mapping, retry logic, and form integrations. ![WordPress Webhook Smart Retry in Action thumbnail](https://wpwebhooks.org/videos/thumbnails/smart_retry_and_reply.webp) ▶ Play 50s · Retry Logic ## WordPress Webhook Smart Retry in Action [Failed delivery → exponential backoff → re-delivery](https://wpwebhooks.org/videos/wordpress-webhook-smart-retry/) ![WordPress Webhook Payload Mapping via Plugin UI thumbnail](https://wpwebhooks.org/videos/thumbnails/payload_mapping_via_UI.webp) ▶ Play 58s · Payload Mapping ## WordPress Webhook Payload Mapping via Plugin UI [Select fields, rename keys, save — no PHP required](https://wpwebhooks.org/videos/wordpress-webhook-payload-mapping/) ![Contact Form 7 to Webhook — End-to-End Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/cf7_to_webhook.webp) ▶ Play 15s · CF7 Integration ## Contact Form 7 to Webhook — End-to-End Walkthrough [CF7 submit → Webhook Actions → n8n payload](https://wpwebhooks.org/videos/contact-form-7-webhook-walkthrough/) ![Gravity Forms to Webhook — Full Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_to_webhook.webp) ▶ Play 22s · Gravity Forms ## Gravity Forms to Webhook — Full Walkthrough [Form submit → gform\_after\_submission → n8n delivery](https://wpwebhooks.org/videos/gravity-forms-webhook-full-walkthrough/) ![Create a Gravity Forms Webhook in WordPress thumbnail](https://wpwebhooks.org/videos/thumbnails/create_gform_webhook.webp) ▶ Play 20s · Gravity Forms Setup ## Create a Gravity Forms Webhook in WordPress [Add endpoint URL, select trigger, filter by form ID](https://wpwebhooks.org/videos/gravity-forms-create-webhook/) ![Gravity Forms Webhook Submission — Event Log & Payload thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_submitted.webp) ▶ Play 20s · Event Log ## Gravity Forms Webhook Submission — Event Log & Payload [HTTP status · attempt count · full JSON payload](https://wpwebhooks.org/videos/gravity-forms-webhook-submission/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"ItemList","name":"WordPress Webhook Screencasts & Video Tutorials","url":"https://wpwebhooks.org/videos/","numberOfItems":6,"itemListElement":[{"@type":"ListItem","position":1,"name":"WordPress Webhook Smart Retry in Action","url":"https://wpwebhooks.org/videos/wordpress-webhook-smart-retry/"},{"@type":"ListItem","position":2,"name":"WordPress Webhook Payload Mapping via Plugin UI","url":"https://wpwebhooks.org/videos/wordpress-webhook-payload-mapping/"},{"@type":"ListItem","position":3,"name":"Contact Form 7 to Webhook — End-to-End Walkthrough","url":"https://wpwebhooks.org/videos/contact-form-7-webhook-walkthrough/"},{"@type":"ListItem","position":4,"name":"Gravity Forms to Webhook — Full Walkthrough","url":"https://wpwebhooks.org/videos/gravity-forms-webhook-full-walkthrough/"},{"@type":"ListItem","position":5,"name":"Create a Gravity Forms Webhook in WordPress","url":"https://wpwebhooks.org/videos/gravity-forms-create-webhook/"},{"@type":"ListItem","position":6,"name":"Gravity Forms Webhook Submission — Event Log & Payload","url":"https://wpwebhooks.org/videos/gravity-forms-webhook-submission/"}]} ``` --- --- title: "WordPress Webhook Smart Retry in Action — Screencast" description: "A 50-second screencast capturing a complete webhook retry cycle using Webhook Actions. A webhook fires to a temporarily unavailable endpoint — the plugin logs t" url: "https://wpwebhooks.org/videos/wordpress-webhook-smart-retry/" --- [WP Webhooks](https://wpwebhooks.org/) / [Videos](https://wpwebhooks.org/videos/) / WordPress Webhook Smart Retry in Action 50s · Retry Logic # WordPress Webhook Smart Retry in Action Failed delivery → exponential backoff → re-delivery A 50-second screencast capturing a complete webhook retry cycle using Webhook Actions. A webhook fires to a temporarily unavailable endpoint — the plugin logs the failure, queues a retry with exponential backoff (1 min → 2 min → 4 min), and successfully re-delivers when the endpoint recovers. The Event Log shows each attempt with HTTP status code and response time. / Related docs [Smart Retry with Exponential Backoff](https://wpwebhooks.org/docs/smart-retry/)[Delivery Logs](https://wpwebhooks.org/docs/delivery-logs/) / More screencasts ![WordPress Webhook Payload Mapping via Plugin UI thumbnail](https://wpwebhooks.org/videos/thumbnails/payload_mapping_via_UI.webp) 58s · Payload Mapping [WordPress Webhook Payload Mapping via Plugin UI](https://wpwebhooks.org/videos/wordpress-webhook-payload-mapping/) ![Contact Form 7 to Webhook — End-to-End Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/cf7_to_webhook.webp) 15s · CF7 Integration [Contact Form 7 to Webhook — End-to-End Walkthrough](https://wpwebhooks.org/videos/contact-form-7-webhook-walkthrough/) ![Gravity Forms to Webhook — Full Walkthrough thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_to_webhook.webp) 22s · Gravity Forms [Gravity Forms to Webhook — Full Walkthrough](https://wpwebhooks.org/videos/gravity-forms-webhook-full-walkthrough/) ![Create a Gravity Forms Webhook in WordPress thumbnail](https://wpwebhooks.org/videos/thumbnails/create_gform_webhook.webp) 20s · Gravity Forms Setup [Create a Gravity Forms Webhook in WordPress](https://wpwebhooks.org/videos/gravity-forms-create-webhook/) ![Gravity Forms Webhook Submission — Event Log & Payload thumbnail](https://wpwebhooks.org/videos/thumbnails/gform_submitted.webp) 20s · Event Log [Gravity Forms Webhook Submission — Event Log & Payload](https://wpwebhooks.org/videos/gravity-forms-webhook-submission/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"VideoObject","name":"WordPress Webhook Smart Retry in Action","description":"A 50-second screencast capturing a complete webhook retry cycle using Webhook Actions. A webhook fires to a temporarily unavailable endpoint — the plugin logs the failure, queues a retry with exponential backoff (1 min → 2 min → 4 min), and successfully re-delivers when the endpoint recovers. The Event Log shows each attempt with HTTP status code and response time.","thumbnailUrl":"https://wpwebhooks.org/videos/thumbnails/smart_retry_and_reply.webp","contentUrl":"https://wpwebhooks.org/videos/smart_retry_and_reply.mp4","duration":"PT50S","uploadDate":"2026-04-07T00:00:00+00:00","publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"}} ``` --- --- title: "AI API Cost Calculator: Price Any LLM Call by Model" description: "Free AI cost calculator: set input and output tokens, pick from 45 LLM models, and see the real per-call, monthly and annual API bill." url: "https://wpwebhooks.org/tools/llm-cost-calculator/" date: "2026-07-29" --- [WP Webhooks](https://wpwebhooks.org/)/ [Tools](https://wpwebhooks.org/tools/)/ AI cost calculator # AI Cost Calculator for LLM API Calls Set the token size of a real request, pick a model, and see what it actually costs — per call, per month, per year. Rates verified 13 September 2026 / 45 models / 6 providers cost = (input\_tokens × input\_rate + output\_tokens × output\_rate) ÷ 1,000,000 Model Promotional rate through 31 Dec 2026 Input tokens 4,700 $0.00352 — the prompt, system message and tool schemas you send. Output tokens 390 $0.00146 — billed at 5.0× the input rate on this model. Reasoning tokens count here. Cached input 0% Cache hits bill at $0.075 / 1M. Calls per month 10,000 Input cost $0.00352 Output cost $0.00146 Per-call cost $0.00499 Output is 7.7% of the tokens but 29% of the cost. Per month $49.88 Per year $598.50 Rate expires 31 December 2026 $598.50/yr assumes the introductory rate holds for 12 months. It does not. From 1 January 2027 this becomes Gemini 3.6 Flash (standard) at $1.5 / $7.5, or $1,197/yr — 100% more. Budget against that. If you resell this as credits weighted = 4,700 + 5.0 × 390 = 6,650 → 7 credits at 1,000 weighted tokens each One credit backs $0.00071 of spend on this model, for any input/output mix. Cheapest for this call is gpt-5-nano at $0.00039 — 12.8× less than your pick. Over a year that is $551.58 of difference. ## The same call, every model 4,700 input and 390 output tokens priced against all 45 models. Cheapest to most expensive — a 540× spread for identical work. | # | Model | Provider | Per call | Per year | vs cheapest | | --- | --- | --- | --- | --- | --- | | 1 | gpt-5-nano | OpenAI | $0.00039 | $46.92 | — | | 2 | GLM-4.7-FlashX | Z.ai | $0.00049 | $58.20 | 1.2× | | 3 | GLM-5.3-Flash | Z.ai | $0.0009 | $108.00 | 2.3× | | 4 | GLM-4.5-Air | Z.ai | $0.00137 | $164.28 | 3.5× | | 5 | gpt-5.6-luna | OpenAI | $0.00141 | $168.96 | 3.6× | | 6 | gpt-5.4-nano | OpenAI | $0.00143 | $171.30 | 3.7× | | 7 | Gemini 3.1 Flash-Lite | Google | $0.00176 | $211.20 | 4.5× | | 8 | deepseek-flash (was V4 Flash) | DeepSeek | $0.00188 | $225.36 | 4.8× | | 9 | gpt-5-mini | OpenAI | $0.00196 | $234.60 | 5.0× | | 10 | Gemini 3.5 Flash-Lite | Google | $0.00238 | $286.20 | 6.1× | | 11 | GLM-4.7 | Z.ai | $0.00368 | $441.36 | 9.4× | | 12 | Gemini 3.6 Flash (promo) | Google | $0.00499 | $598.50 | 12.8× | | 13 | Gemini 3.7 Flash (promo) | Google | $0.00499 | $598.50 | 12.8× | | 14 | Gemini 3.8 Flash (promo) | Google | $0.00499 | $598.50 | 12.8× | | 15 | gpt-5.4-mini | OpenAI | $0.00528 | $633.60 | 13.5× | | 16 | GLM-5 | Z.ai | $0.00595 | $713.76 | 15.2× | | 17 | Kimi K2.6 | Moonshot | $0.00602 | $723.00 | 15.4× | | 18 | Claude Haiku 4.5 | Anthropic | $0.00665 | $798.00 | 17.0× | | 19 | deepseek-v4-pro | DeepSeek | $0.00775 | $929.81 | 19.8× | | 20 | GLM-5.2 | Z.ai | $0.0083 | $995.52 | 21.2× | | 21 | GLM-5.3 | Z.ai | $0.0083 | $995.52 | 21.2× | | 22 | gpt-5 | OpenAI | $0.00977 | $1,173 | 25.0× | | 23 | gpt-5.1 | OpenAI | $0.00977 | $1,173 | 25.0× | | 24 | Gemini 3.6 Flash (standard) | Google | $0.00997 | $1,197 | 25.5× | | 25 | Gemini 3.7 Flash (standard) | Google | $0.00997 | $1,197 | 25.5× | | 26 | Gemini 3.8 Flash (standard) | Google | $0.00997 | $1,197 | 25.5× | | 27 | Gemini 3.5 Flash | Google | $0.0106 | $1,267 | 27.0× | | 28 | Claude Sonnet 5 | Anthropic | $0.0133 | $1,596 | 34.0× | | 29 | gpt-5.2 | OpenAI | $0.0137 | $1,642 | 35.0× | | 30 | gpt-5.6-terra | OpenAI | $0.0141 | $1,690 | 36.0× | | 31 | Gemini 3.1 Pro | Google | $0.0141 | $1,690 | 36.0× | | 32 | gpt-5.4 | OpenAI | $0.0176 | $2,112 | 45.0× | | 33 | Kimi K3 | Moonshot | $0.0199 | $2,394 | 51.0× | | 34 | gpt-5.6-solpremium | OpenAI | $0.0266 | $3,192 | 68.0× | | 35 | Claude Opus 5 | Anthropic | $0.0333 | $3,990 | 85.0× | | 36 | gpt-5.5premium | OpenAI | $0.0352 | $4,224 | 90.0× | | 37 | gpt-6-astrapremium | OpenAI | $0.0665 | $7,980 | 170.1× | | 38 | Claude Fable 5premium | Anthropic | $0.0665 | $7,980 | 170.1× | | 39 | Claude Fable 5.1premium | Anthropic | $0.0665 | $7,980 | 170.1× | | 40 | Claude Mythos 5premium | Anthropic | $0.0665 | $7,980 | 170.1× | | 41 | Claude Mythos 5.1premium | Anthropic | $0.0665 | $7,980 | 170.1× | | 42 | gpt-5-propremium | OpenAI | $0.1173 | $14,076 | 300.0× | | 43 | gpt-5.2-propremium | OpenAI | $0.1642 | $19,706 | 420.0× | | 44 | gpt-5.4-propremium | OpenAI | $0.2112 | $25,344 | 540.2× | | 45 | gpt-5.5-propremium | OpenAI | $0.2112 | $25,344 | 540.2× | ## What changed since the last check Every rate movement caught between 21 August 2026 and 13 September 2026, re-read from each provider's own documentation. The change column is the move in the cost of a 4,700 / 390 call, not in the headline rate. | Model | Provider | Was | Now | Per call | | --- | --- | --- | --- | --- | | Claude Sonnet 5 The scheduled 1 Sep increase to $3 / $15 was cancelled — the $2 / $10 introductory rate is now the standard price | Anthropic | $3.00 / $15.00 | $2.00 / $10.00 | \-33% | | Claude Fable 5.1 New model at the Fable 5 rate; cache hits $0.25 (0.025×) instead of $1.00 | Anthropic | — | $10.00 / $50.00 | new | | Claude Mythos 5.1 New model, limited availability, same rates as Fable 5.1 | Anthropic | — | $10.00 / $50.00 | new | | gpt-6-astra New premium model; cached input $1.00 | OpenAI | — | $10.00 / $50.00 | new | | Gemini 3.8 Flash New model at the 3.6 / 3.7 Flash promotional rate through 31 Dec 2026, then $1.50 / $7.50 | Google | — | $0.75 / $3.75 | new | | deepseek-flash (was deepseek-v4-flash) Renamed and cut; cache hits $0.006; peak hours now weekdays only | DeepSeek | $0.44 / $1.32 | $0.30 / $1.20 | \-27% | | GLM-5.3-Flash New budget model | Z.ai | — | $0.15 / $0.50 | new | | Kimi K2.6 Newly tracked here — Moonshot's budget general model; K3 itself is unchanged | Moonshot | — | $0.95 / $4.00 | new | | gpt-5.6-luna | OpenAI | $1.00 / $6.00 | $0.20 / $1.20 | \-80% | | gpt-5.6-terra | OpenAI | $2.50 / $15.00 | $2.00 / $12.00 | \-20% | | gpt-5.6-sol | OpenAI | $5.00 / $30.00 | $4.00 / $20.00 | \-24% | | Gemini 3.6 Flash Promotional rate through 31 Dec 2026, then back to $1.50 / $7.50 | Google | $1.50 / $7.50 | $0.75 / $3.75 | \-50% | | Gemini 3.7 Flash New model, introductory rate through 31 Dec 2026 | Google | — | $0.75 / $3.75 | new | | deepseek-v4-flash Repriced into peak / off-peak tiers; peak shown | DeepSeek | $0.14 / $0.28 | $0.44 / $1.32 | +237% | | deepseek-v4-pro Repriced into peak / off-peak tiers; peak shown | DeepSeek | $0.435 / $0.87 | $1.32 / $3.96 | +225% | | GLM-5.3 New model at the GLM-5.2 rate | Z.ai | — | $1.40 / $4.40 | new | **How to read this.** Rates are per million tokens, taken from each provider's own documentation on 13 September 2026. Published pricing changes often — check the source before you budget against it. - The default 4,700 / 390 split is a planning or tool-calling request: a large prompt, a short structured answer. Chat workloads invert it, which changes the ranking. - Cached input is only applied to models whose provider publishes a flat cache-hit rate. The rest ignore the slider rather than guess. - Batch APIs on OpenAI, Google and Anthropic discount both directions by 50% — halve any figure here if the work is not interactive. The reasoning behind the credit formula, the output-weight ratios, and where each rate came from is written up in [what an AI feature actually costs per call](https://wpwebhooks.org/blog/llm-api-pricing-cost-per-call/). /FAQ ## Questions about AI pricing. Rates come from each provider's own documentation — the full write-up is in the LLM cost article. How do you calculate the cost of an LLM API call? + Multiply input tokens by the model input rate, output tokens by the output rate, and divide by 1,000,000. On Gemini 3.6 Flash ($0.75 in / $3.75 out) a 4,700-input, 390-output call is (4,700 × 0.75 + 390 × 3.75) ÷ 1,000,000 = $0.00499. Every provider bills in exactly that shape, so the only hard part is knowing your real token size. Why does the calculator default to 4,700 input and 390 output tokens? + That is a measured production request for a planning or tool-calling task: a large system prompt plus tool schemas going in, a short structured decision coming back. It is deliberately not a chat workload, which inverts the ratio and changes which model is cheapest. Move the sliders to match your own traffic — and expect real calls to run 1.5 to 2 times larger than a thin test sample. Why are output tokens more expensive than input tokens? + Generating tokens is sequential, while input can be processed in parallel, and providers price accordingly. The ratio is typically 5× on Anthropic, Google Flash and Kimi K3, 6–8× across the OpenAI GPT-5 line, and 3× on DeepSeek V4. Because reasoning tokens are billed as output, a model that always thinks can cost more than its visible answer suggests. Why is the cached-input slider disabled for some models? + Because that provider does not publish a flat per-token cache-hit rate. OpenAI, Anthropic, Moonshot, DeepSeek and Z.ai all do, so the slider applies their real discount — typically 0.1× the base input rate, and as low as 0.03× on DeepSeek. Only Google is disabled here: its context-caching price carries a separate per-hour storage charge, so a single per-token rate would understate the real cost. Which LLM API is the cheapest? + At the default call size, gpt-5-nano ($0.05 / $0.40 per million) and GLM-4.7-FlashX ($0.07 / $0.40) are the cheapest paid options, followed by GLM-4.5-Air and gpt-5.6-luna at around $0.0014 a call. GLM-5.3-Flash ($0.15 / $0.50) is new in that group. The DeepSeek Flash model left it in August 2026 at $0.44 / $1.32 and is back on its edge since September as deepseek-flash at $0.30 / $1.20. Cheapest only matters if quality holds for your task, so A/B a candidate on real requests before switching, and note that naming is not a price guide: Kimi K3 costs half as much again per call as Claude Sonnet 5. How should I charge credits if I resell AI usage? + Charge from the token counts the provider returns, not a flat price per request, because a flat rate always undercharges your largest calls. Weight output tokens by the model output-to-input price ratio, divide by a fixed tokens-per-credit constant, and settle after the call succeeds. One credit then backs a fixed amount of provider cost for any input/output mix. How current are these prices? + Every rate was read from the provider's own documentation on 13 September 2026 — never from aggregator sites, which drift. Published pricing changes often, and introductory rates expire — Gemini 3.6, 3.7 and 3.8 Flash all double on 1 January 2027 — though not always: Claude Sonnet 5's planned move from $2/$10 to $3/$15 on 1 September 2026 was cancelled, and $2/$10 is now its standard rate. The change log on this page records every rate movement we have caught since 29 July 2026, including a 50% cut on Gemini 3.6 Flash and a roughly 3× rise on DeepSeek V4. Verify against the provider before you budget against any figure here. /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"WebApplication","name":"AI API Cost Calculator","url":"https://wpwebhooks.org/tools/llm-cost-calculator/","applicationCategory":"DeveloperApplication","operatingSystem":"Any","browserRequirements":"Requires JavaScript","inLanguage":"en","description":"Free AI cost calculator: set input and output tokens, pick from 45 LLM models, and see the real per-call, monthly and annual API bill.","datePublished":"2026-07-29","dateModified":"2026-09-13","isAccessibleForFree":true,"offers":{"@type":"Offer","price":"0","priceCurrency":"USD"},"featureList":["Per-call cost for 45 models across 6 providers","Adjustable input and output token counts","Cached-input pricing where the provider publishes a cache-hit rate","Monthly and annual projections from call volume","Token-linked credit charge for reselling AI usage"],"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"}} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Tools","item":"https://wpwebhooks.org/tools/"},{"@type":"ListItem","position":3,"name":"AI API Cost Calculator","item":"https://wpwebhooks.org/tools/llm-cost-calculator/"}]} {"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do you calculate the cost of an LLM API call?","acceptedAnswer":{"@type":"Answer","text":"Multiply input tokens by the model input rate, output tokens by the output rate, and divide by 1,000,000. On Gemini 3.6 Flash ($0.75 in / $3.75 out) a 4,700-input, 390-output call is (4,700 × 0.75 + 390 × 3.75) ÷ 1,000,000 = $0.00499. Every provider bills in exactly that shape, so the only hard part is knowing your real token size."}},{"@type":"Question","name":"Why does the calculator default to 4,700 input and 390 output tokens?","acceptedAnswer":{"@type":"Answer","text":"That is a measured production request for a planning or tool-calling task: a large system prompt plus tool schemas going in, a short structured decision coming back. It is deliberately not a chat workload, which inverts the ratio and changes which model is cheapest. Move the sliders to match your own traffic — and expect real calls to run 1.5 to 2 times larger than a thin test sample."}},{"@type":"Question","name":"Why are output tokens more expensive than input tokens?","acceptedAnswer":{"@type":"Answer","text":"Generating tokens is sequential, while input can be processed in parallel, and providers price accordingly. The ratio is typically 5× on Anthropic, Google Flash and Kimi K3, 6–8× across the OpenAI GPT-5 line, and 3× on DeepSeek V4. Because reasoning tokens are billed as output, a model that always thinks can cost more than its visible answer suggests."}},{"@type":"Question","name":"Why is the cached-input slider disabled for some models?","acceptedAnswer":{"@type":"Answer","text":"Because that provider does not publish a flat per-token cache-hit rate. OpenAI, Anthropic, Moonshot, DeepSeek and Z.ai all do, so the slider applies their real discount — typically 0.1× the base input rate, and as low as 0.03× on DeepSeek. Only Google is disabled here: its context-caching price carries a separate per-hour storage charge, so a single per-token rate would understate the real cost."}},{"@type":"Question","name":"Which LLM API is the cheapest?","acceptedAnswer":{"@type":"Answer","text":"At the default call size, gpt-5-nano ($0.05 / $0.40 per million) and GLM-4.7-FlashX ($0.07 / $0.40) are the cheapest paid options, followed by GLM-4.5-Air and gpt-5.6-luna at around $0.0014 a call. GLM-5.3-Flash ($0.15 / $0.50) is new in that group. The DeepSeek Flash model left it in August 2026 at $0.44 / $1.32 and is back on its edge since September as deepseek-flash at $0.30 / $1.20. Cheapest only matters if quality holds for your task, so A/B a candidate on real requests before switching, and note that naming is not a price guide: Kimi K3 costs half as much again per call as Claude Sonnet 5."}},{"@type":"Question","name":"How should I charge credits if I resell AI usage?","acceptedAnswer":{"@type":"Answer","text":"Charge from the token counts the provider returns, not a flat price per request, because a flat rate always undercharges your largest calls. Weight output tokens by the model output-to-input price ratio, divide by a fixed tokens-per-credit constant, and settle after the call succeeds. One credit then backs a fixed amount of provider cost for any input/output mix."}},{"@type":"Question","name":"How current are these prices?","acceptedAnswer":{"@type":"Answer","text":"Every rate was read from the provider's own documentation on 13 September 2026 — never from aggregator sites, which drift. Published pricing changes often, and introductory rates expire — Gemini 3.6, 3.7 and 3.8 Flash all double on 1 January 2027 — though not always: Claude Sonnet 5's planned move from $2/$10 to $3/$15 on 1 September 2026 was cancelled, and $2/$10 is now its standard rate. The change log on this page records every rate movement we have caught since 29 July 2026, including a 50% cut on Gemini 3.6 Flash and a roughly 3× rise on DeepSeek V4. Verify against the provider before you budget against any figure here."}}]} ``` --- --- title: "Free Developer Tools: AI Cost Calculator & Webhooks" description: "Small, focused calculators and utilities for people wiring WordPress to other systems — starting with an AI cost calculator covering 36 LLM models." url: "https://wpwebhooks.org/tools/" --- [WP Webhooks](https://wpwebhooks.org/)/ Tools # Developer tools Small, focused utilities for people wiring WordPress to other systems. Free, no sign-up, everything runs in your browser. AI AI API Cost Calculator Set input and output tokens, pick from 36 models across OpenAI, Google, Anthropic, Moonshot, DeepSeek and Z.ai, and see the per-call, monthly and annual bill — plus the token-linked credit charge if you resell it. [Use →](https://wpwebhooks.org/tools/llm-cost-calculator/) /Ready ## Your next automation is one sentence away. [Install Plugin→](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip) [Try the Live Preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [See the Plugin →](https://wpwebhooks.org/wordpress-webhook-plugin/) $ wp plugin install flowsystems-webhook-actions --activate ## Structured data ```json {"@context":"https://schema.org","@type":"ItemList","name":"Developer tools","url":"https://wpwebhooks.org/tools/","numberOfItems":1,"itemListElement":[{"@type":"ListItem","position":1,"name":"AI API Cost Calculator","url":"https://wpwebhooks.org/tools/llm-cost-calculator/"}]} {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Tools","item":"https://wpwebhooks.org/tools/"}]} ``` ---