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_dataand$entry_id— everything needed to build your own payload. $entry_idis0on 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, 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.
/ 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 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 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. 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 and Contact Form 7 submissions, 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 |
/ 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.
- 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. - 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. - Check the argument count. If
$form_dataisnull, the fourth argument toadd_actionis missing or wrong. - Log the response, not just the request.
wp_remote_postreturns either aWP_Erroror an array — logwp_remote_retrieve_response_code()so a 401 is not mistaken for a request that never left. - Rule out the outbound connection. A host that blocks external HTTP, or a firewall that drops the destination, produces a
WP_Errorwith 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.