name: jfb-action-external-api description: How to read submitted JetFormBuilder form data from a custom action, transform it (including %macro% replacement of admin-typed templates), call an external HTTP API, write the response back into the form context for downstream actions to use, and dispatch JFB events based on the API outcome. Use when building a custom action that integrates with a third-party service (LLM call, webhook, CRM, payment processor, geolocation lookup) and needs the action to behave as a node in a data-flow graph rather than a one-shot leaf. Triggers on mentions of "jet_fb_context", "update_request", "has_field", "get_value", "wp_remote_post" with form data, "macro replacement", or "API call from action". author: Soczó Kristóf contact: mailto:lonsdale201@hotmail.com plugin: jetformbuilder plugin-version-tested: "3.5.6" api-stable-since: "3.0" php-min: "7.4" last-updated: "2026-04-28" docs: - https://github.com/Crocoblock/developer-documentation/tree/main/03-jet-form-builder - https://developer.wordpress.org/reference/functions/wp_remote_post/ source-refs: - includes/context/context.php
JetFormBuilder: action — read form data, call external API, write result back
Most custom JFB actions follow the same data-flow shape: pull values out of the submitted form, optionally interpolate admin-typed templates with %field% macros, hit an external HTTP API, transform the response, write it back into the form context so downstream actions can read it, and (optionally) dispatch a custom JFB event based on the outcome to fan out to conditional follow-up actions.
This skill covers that full lifecycle inside do_action(). It complements jfb-form-action (which covers the action class structure) and jfb-action-events (which covers event dispatch). This one is specifically about the PHP-side data flow — the API surface JFB exposes for read/write/macro/dispatch from inside an action.
API stability note
The jet_fb_context() global (with get_value, update_request, has_field, resolve_request), Tools::sanitize_text_field(), and the Tab_Handler_Manager::instance()->options( $tab_id ) accessor have been stable across JFB 3.x in source observed. The WordPress HTTP API (wp_remote_post, wp_remote_retrieve_body, WP_Error handling) is WP core. The plugin-version-tested value records last end-to-end verification.
When to use this skill
- A custom action calls a third-party HTTP API as part of its work.
- An action needs to read multiple form fields and interpolate them into a request body or instructions string.
- The admin should be able to write templates like
"Verify %email% for order %order_id%"and have JFB substitute the field values. - The API response should be written back into the form context so other actions can use it (for example, a "ChatGPT Decision" action writing the answer into a hidden form field that a "Send Email" action then includes).
- The action should branch the action chain based on the API response (success/failure/decision).
- The diff/files contain
jet_fb_context(),update_request,wp_remote_post,wp_remote_retrieve_body,WP_Error, or macro substitution patterns like%[a-z_]+%.
Architecture in one paragraph
JFB exposes the live form submission as a singleton via jet_fb_context(). From inside do_action( array $request, Action_Handler $handler ), the $request parameter is an associative array of field IDs to current values, but the same data is also available through jet_fb_context()->get_value( $field_id ) — and the context is the source of truth: subsequent actions read from it. To write a value back (so downstream actions see it), call jet_fb_context()->update_request( $value, $field_id ). Admin-typed templates with %field% placeholders are interpolated by a small replacer that calls jet_fb_context()->has_field() and get_value(). External API calls go through WordPress's HTTP API (wp_remote_post etc.) with proper timeout, header, and error handling — never file_get_contents or curl_* directly. API credentials come from the plugin's settings tab via Tab_Handler_Manager::instance()->options( $tab_id ). Outcome branching is done by dispatching a custom event through jet_fb_events()->execute( EventClass::class, $form_id ) — this fans out the chain without the action knowing which followups exist.
Step 1 — read the form data
There are two correct ways to read a field's submitted value, with different semantics:
public function do_action( array $request, Action_Handler $handler ) {
$email = $request['email_field_id'] ?? ''; // direct from request
$email = jet_fb_context()->get_value( 'email_field_id' ); // through context
}
Use $request[...] when reading values that won't be changed by previous actions in the chain (most common case — submitted-as-is data).
Use jet_fb_context()->get_value( $field_id ) when:
- A previous action might have called
update_request()to mutate a value (e.g. a translation action overwriting a field with a translated copy). - You want to write back later — for symmetry, read through the same API.
- You want to fall through
has_field()cleanly when the field doesn't exist on the form.
get_value() returns the current context value (post any updates). $request is the snapshot passed to your do_action. They diverge only when intermediate actions mutate; if you control the chain, both work. If in doubt, prefer the context API — it's the source of truth.
Step 2 — sanitize and validate
Form data has been validated by JFB's field rules but NOT sanitized for your specific output target. Your action is responsible for sanitization that fits how the data leaves your code.
$email = sanitize_email( (string) $email );
if ( ! is_email( $email ) ) {
throw new Action_Exception(
__( 'Provided email is not valid.', 'myplugin' )
);
}
// For text passed to an external API (no HTML expected):
$instructions = sanitize_textarea_field( $this->settings['instructions'] ?? '' );
// For values that act as identifiers (no whitespace, no special chars):
$external_id = Tools::sanitize_text_field( $request['external_id'] ?? '' );
// For numeric IDs:
$user_id = absint( $request['user_id'] ?? 0 );
Tools::sanitize_text_field() is JFB's wrapper that trims plus does the standard sanitize_text_field work. Use it for action settings that the user typed in the editor.
Step 3 — macro replacement (%field_id% in admin templates)
A common pattern: the admin types a template like "Hi %first_name%, your order %order_id% is ready" in an action setting, and you substitute the form's actual field values at runtime. The replacer reads jet_fb_context() so it stays in sync with any mutations from upstream actions.
private function replace_macros( string $template ): string {
if ( false === strpos( $template, '%' ) ) {
return $template;
}
return preg_replace_callback(
'/%(?P<name>[a-zA-Z0-9\-_]+)%/',
static function ( $match ) {
$field = $match['name'];
if ( ! jet_fb_context()->has_field( $field ) ) {
return $match[0]; // leave the placeholder as-is
}
$value = jet_fb_context()->get_value( $field );
if ( is_array( $value ) || is_object( $value ) ) {
$value = wp_json_encode( $value );
}
return (string) $value;
},
$template
);
}
Notes:
- Regex matches
[a-zA-Z0-9\-_]+for the macro name — adjust if your field IDs allow other characters, but JFB field IDs are normally constrained to this set. - Skip the early-out (
strpos) at your peril — without it, every empty-template render runs a regex pass. - Unknown macro is left literal, not stripped. The admin should see
%not_a_field%in the output if they typo'd, not silently nothing. - Array / object values get JSON-encoded because they have to become a string. If your context expects something else (CSV, comma-joined), customize the inner branch.
- The macro replacer is
static— it has no instance state. Make it a private method or a free function.
JFB ships its own macro / preset / dynamic-value subsystems for richer cases (date formatters, computed values, related-post lookups). If your needs grow past simple field substitution, look into jet_fb_dynamic_value() or the macros module instead of expanding this regex.
Step 4 — call the external API (WP HTTP API)
Always use wp_remote_* — never file_get_contents, curl_init, fopen for HTTP. WordPress's HTTP API handles transport selection, proxy config, SSL verify, and timeout consistently across hosts.
private function call_external_api( array $payload ): array {
$settings = $this->get_api_settings();
$api_key = trim( (string) ( $settings['api_key'] ?? '' ) );
if ( '' === $api_key ) {
throw new Action_Exception(
__( 'API key is missing. Configure it in the plugin settings.', 'myplugin' )
);
}
$response = wp_remote_post(
'https://api.example.com/v1/endpoint',
array(
'headers' => array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
),
'body' => wp_json_encode( $payload ),
'timeout' => 30,
)
);
if ( $response instanceof \WP_Error ) {
throw new Action_Exception(
sprintf(
/* translators: %s: HTTP error message */
__( 'API request failed: %s', 'myplugin' ),
$response->get_error_message()
)
);
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = (string) wp_remote_retrieve_body( $response );
if ( $code < 200 || $code >= 300 ) {
throw new Action_Exception(
sprintf(
/* translators: 1: HTTP status code, 2: response body excerpt */
__( 'API returned %1$d: %2$s', 'myplugin' ),
$code,
wp_strip_all_tags( substr( $body, 0, 200 ) )
)
);
}
$decoded = json_decode( $body, true );
if ( ! is_array( $decoded ) ) {
throw new Action_Exception(
__( 'API returned a non-JSON response.', 'myplugin' )
);
}
return $decoded;
}
Critical points:
timeout— set explicitly. WP default is 5 seconds which is too short for many APIs. 30 seconds is reasonable for most LLM/CRM calls; raise only if you know the upstream is slower.headers— always includeContent-Typefor POST bodies, andAcceptfor clarity.Authorizationfor any auth scheme.body—wp_json_encode, never plainjson_encode(the WP wrapper handles charsets and edge cases).WP_Errorhandling —wp_remote_*returns either an array on success OR aWP_Erroron transport failure. Always check.- HTTP status code —
wp_remote_retrieve_response_codeis the canonical way to read it. Convert to int, check the 2xx range. A 200-OK with malformed body still throws below. - Body excerpt in error message —
substr( $body, 0, 200 )+wp_strip_all_tagskeeps user-visible errors short and HTML-safe. Don't dump the full body — APIs sometimes return huge HTML error pages. - Throw
Action_Exception, notException— JFB only translatesAction_Exceptionto user-facing messages.
For SSRF protection when the URL is admin-configurable (e.g. webhook URLs the user types in), additionally validate the URL host against an allowlist or use the reject_unsafe_urls request arg. See wp-security-deep for the full SSRF discussion.
Step 5 — write the result back into form context
When your action produces data that downstream actions should see (e.g. ChatGPT writes its answer into a hidden field, a translation action replaces a value with its translated form), use update_request:
$answer = $api_response['choices'][0]['message']['content'] ?? '';
$answer = sanitize_textarea_field( $answer );
jet_fb_context()->update_request( $answer, $target_field_id );
Key semantics:
update_request( $value, $field_id )— argument order is(value, field_id), NOT(field_id, value). Easy to flip.- Writing to a non-existent field is silent — the value is stored and
has_fieldwill return true after, but nothing visible in the form. To verify before writing, callhas_fieldfirst. - Subsequent actions read the new value via both
$request[ $field_id ](theirdo_action's$requestis re-resolved from context) ANDjet_fb_context()->get_value( $field_id ). Both are in sync afterupdate_request. - Save Record action snapshots the request at submission end — values you write here will be in the saved submission.
If your action ALSO wants to pass non-field metadata to specific downstream actions (e.g. an internal API transaction ID, a calculated score), use $handler->add_context_once( $this->get_id(), [...] ) instead. That's keyed by action type, not exposed as a form field, and accessible via $handler->get_context( $action_type, $key ) in later actions.
Step 6 — dispatch outcome events
Once you have the API result and have written it back, branch the chain by dispatching a custom event. Each branch can have its own followups (different actions wired to different events).
use MyPlugin\Events\ApiSuccessEvent;
use MyPlugin\Events\ApiFailureEvent;
if ( $api_response['ok'] ?? false ) {
jet_fb_events()->execute( ApiSuccessEvent::class, $handler->get_form_id() );
} else {
jet_fb_events()->execute( ApiFailureEvent::class, $handler->get_form_id() );
}
Important caveats from observed production usage:
- Save the action handler's position before dispatching, restore after — the dispatch reuses the action handler and shifts its iteration cursor, which can confuse the surrounding chain:
$previous_position = $handler->get_position();
try {
jet_fb_events()->execute( $event_class, $handler->get_form_id() );
} finally {
if ( $previous_position ) {
$handler->set_current_action( $previous_position );
} else {
$handler->set_current_action( $this->_id );
}
}
- Pass the form ID as the second argument. Some event executors need it.
- Events must be registered server-side via
'jet-form-builder/event-types'filter (seejfb-action-events). - Listeners must opt-in to the event in the editor — this is the standard
provideEventsflow OR the action item decorator pattern (seejfb-action-item-decorator).
Step 7 — credentials: read from settings tab, not constants
API keys and similar secrets should live in the plugin's settings tab (admin-managed, stored in wp_options). Don't hardcode, don't ship wp-config.php constants for plugin features — those are for the site owner.
private function get_api_settings(): array {
$defaults = array(
'api_key' => '',
'model' => 'gpt-4',
'enable_log' => false,
);
return array_merge(
$defaults,
Tab_Handler_Manager::instance()->options( 'myplugin-api-tab', array() )
);
}
Tab_Handler_Manager::instance()->options( $tab_id, $defaults ) reads the JFB settings tab's stored options — see jfb-settings-tab for how to register the tab itself. This pattern keeps the action code agnostic to where the credentials live, and makes it trivial for site admins to rotate keys.
Step 8 — debug logging (gated)
Production actions called from form submissions can flood logs if you log indiscriminately. Gate logs behind a settings flag:
private function should_log(): bool {
static $enabled = null;
if ( null !== $enabled ) {
return $enabled;
}
$options = Tab_Handler_Manager::instance()->options( 'myplugin-api-tab', array() );
$enabled = ! empty( $options['enable_log'] );
return $enabled;
}
private function log( string $message ): void {
if ( ! $this->should_log() ) {
return;
}
error_log( '[MyPlugin] ' . $message );
}
The static $enabled cache avoids re-reading options on every log call within one request.
What to log:
- Request payload (sanitized — strip
Authorizationheaders, redact secrets) - API response status code and a short excerpt of the body
- Final outcome / decision
What NOT to log:
- API keys, tokens, full headers
- PII unless the user has opted in
- Full response bodies for high-volume endpoints
See wp-security-secrets for the full secrets-in-logs discussion.
End-to-end example: complete do_action()
/**
* @throws Action_Exception
*/
public function do_action( array $request, Action_Handler $handler ) {
// 1. Read settings (template + target field).
$instructions = sanitize_textarea_field( $this->settings['instructions'] ?? '' );
$target_field = Tools::sanitize_text_field( $this->settings['fields_map']['answer'] ?? '' );
if ( '' === $target_field ) {
throw new Action_Exception(
__( 'Target field is required.', 'myplugin' )
);
}
// 2. Macro-replace admin templates.
$prompt = $this->replace_macros( $instructions );
// 3. Call the API.
$response = $this->call_external_api( array(
'prompt' => $prompt,
'model' => $this->get_api_settings()['model'],
) );
// 4. Sanitize + write the result back into form context.
$answer = sanitize_textarea_field( $response['answer'] ?? '' );
jet_fb_context()->update_request( $answer, $target_field );
// 5. Dispatch outcome event.
$previous_position = $handler->get_position();
try {
$event_class = ( $response['decision'] ?? false )
? \MyPlugin\Events\DecisionTrueEvent::class
: \MyPlugin\Events\DecisionFalseEvent::class;
jet_fb_events()->execute( $event_class, $handler->get_form_id() );
} finally {
if ( $previous_position ) {
$handler->set_current_action( $previous_position );
} else {
$handler->set_current_action( $this->_id );
}
}
}
This shape — settings → macro → API → context write → event dispatch — covers the vast majority of integration actions. Most of the implementation effort is steering the API specifics; the JFB-side glue is constant.
Critical rules
jet_fb_context()is the source of truth, not$request— both work for reading, but if upstream actions might mutate, preferget_value. For writes, ALWAYS useupdate_request(NOT direct array mutation).update_request( $value, $field_id )argument order is value-first. Easy to mix up.- Sanitize at the boundary — even though JFB validates form fields, your action sends data to a different system with different rules.
sanitize_text_field,sanitize_email,absint,wp_kses_postfor HTML,Tools::sanitize_text_fieldfor action settings. - Use
wp_remote_*for HTTP, nevercurl/file_get_contents/fopenwith URLs. WP's HTTP API is the only correct path on multi-host environments. - Always set explicit
timeout— the default 5s breaks LLM and slow-CRM use cases. - Throw
Action_Exceptionfor failure, never rawException. JFB only displaysAction_Exceptionmessages to the user. - Save and restore handler position around
jet_fb_events()->execute()— the dispatch shifts the iteration cursor. - API keys come from the settings tab via
Tab_Handler_Manager— never hardcode, neverwp-config.phpconstants for plugin-feature credentials. - Gate
error_logbehind a settings flag — production logs flood otherwise. - Strip secrets before logging —
Authorizationheaders, API keys, full bodies of auth-related endpoints. - Validate URL host for SSRF when the URL is user-configurable — see
wp-security-deep. - Macros come from
%field_id%— JFB field ID format. Don't invent your own delimiter; admins expect this convention.
Common pitfalls (failure modes inferred from the API contract)
update_requestwrites nothing visible: target field doesn't exist on the form. Verify withhas_fieldfirst, OR document that the field is required.- Macro replacer eats
%-signs in non-template text: the regex matches greedily over%foo%. If your text legitimately contains percent characters, escape them in the admin help text (100%%) or use a different delimiter. - API call hangs the form submission for 30 seconds: timeout is set, but the user is still waiting. Consider deferring slow calls to a background task (Action Scheduler) and dispatching events on completion. Out of scope here, but worth knowing.
- Action throws on non-2xx, but the user only sees "Submit failed":
Action_Exceptionwas caught somewhere upstream OR a generic\Exceptionwas thrown instead. Always useAction_Exceptionwith a clear, translatable message. jet_fb_context()->get_valuereturns null for a known field: the form was submitted before that field was rendered (conditional block hid it). Use??to fall through to a default; do not throw.- Downstream actions don't see the updated value:
update_requestwas called with arguments swapped (field_idfirst,valuesecond). Re-check argument order. - Event dispatched but no listener runs: events not registered, OR listeners haven't opted-in via
provideEvents/ decorator. Seejfb-action-events. - API key visible in error log: logging the full request body without redaction. Add a redactor before the log call.
Cross-references
- Run
jfb-form-actionfirst — this skill assumes a working action class withaction_attributes,action_data, etc. - Run
jfb-action-eventsto register the events your action dispatches. - Run
jfb-action-item-decoratorif you also want the visual TRUE/FALSE/Always toggle on every action (the canonical UI for action-event branching). - Run
jfb-settings-tabto register the settings tab where API credentials live. - Run
wp-security-deepfor SSRF protection when API URLs are user-configurable. - Run
wp-security-secretsbefore release — credentials must come from the settings tab, never hardcoded. - Run
wp-i18n-auditto verify all error messages are translatable.
What this skill does NOT cover
- Background / async API calls (Action Scheduler integration is a separate topic).
- Streaming API responses (Server-Sent Events, WebSocket) — out of scope.
- Multi-step API flows (OAuth, refresh tokens) — describe the storage of the refresh token, then this skill applies for each call.
- Idempotency keys / retries — depends on upstream API; not JFB-specific.
- File uploads from the form to an external API — possible but with different sanitization (
wp_check_filetype,wp_handle_uploadfirst). - Rate limiting from your side — depends on the API; consider transients with TTL.
References
chatgpt-for-jetformbuilderplugin: production reference. Seeincludes/Actions/ChatgptDecisionAction.phpfor the full pattern (do_action,replace_macros,call_chatgpt,should_log, settings tab integration).- WP HTTP API: https://developer.wordpress.org/reference/functions/wp_remote_post/
jet_fb_context()source:wp-content/plugins/jetformbuilder/includes/context/context.php