Abuja Digital Studio · Est. 2018
Start a Project
Docs
OForms
Lightweight form builder and workflow automation engine — drag-and-drop fields, entry storage, email triggers, REST API, and a clean admin UI.
oforms
Developer documentation

OForms v1.2.0

A WordPress form builder with 23 field types, multi-step forms, conditional logic, an 11-action workflow engine, async email queue, webhook subscriptions, CRM integrations, REST API with key auth, and full data ownership across 10 custom database tables.

WordPress plugin WP 6.0 · PHP 8.1+ Namespace: OForms\* v1.2.0
01 · Overview

What OForms does

Drag-and-drop form builder with 23 field types, multi-step flows, conditional logic, and an 11-action workflow engine. All submissions stored across 10 custom tables.

🧱
23 field types
Text, email, file upload, signature, address, repeater, GDPR consent, calc, heading, HTML embed, hidden, step break, and more
📑
Multi-step forms
Step-break fields split the form; progress bar and per-step validation included
🔀
Conditional logic
Show/hide any field based on another field's value with 10 operators in AND/OR groups
⚙️
Workflows
11 action types including send_email, send_webhook, tag_entry, send_slack, create_wp_user, crm_subscribe. Delayed actions via queue.
📨
Async email queue
Notifications write to wp_of_email_queue and flush via cron at 30/min with backoff
🪝
Webhook subscriptions
HMAC-signed dispatches to Zapier / Make / n8n with per-endpoint test button
💾
Partial entries
Resume drafts via token plus form abandonment capture for funnel recovery
🔌
CRM integrations
Mailchimp, HubSpot, Brevo, ActiveCampaign, ConvertKit out of the box
🗄️
10 custom tables
forms, form_fields, entries, workflows, partial_entries, webhook_subscriptions, views, email_queue, api_keys, queue
📊
Conversion analytics
Form views tracked in wp_of_views with country / device / variant for A/B-style breakdowns
🛡️
reCAPTCHA v3 + GDPR
Score threshold 0.5, honeypot field, GdprManager handles consent and retention purges
🔑
REST API + key auth
Read + write at /wp-json/of/v1, X-OForms-Key header authenticates against wp_of_api_keys
02 · Installation

Getting installed

  • Upload the oforms folder to /wp-content/plugins/
  • Activate through Plugins → Installed Plugins
  • Navigate to OForms in the WP admin menu
  • Database tables are created automatically on first activation
ℹ️Requires WordPress 6.0+, PHP 8.1+, MySQL 5.7+ / MariaDB 10.3+.

Plugin constants

ConstantValue
OF_VERSION1.2.0
OF_PLUGIN_SLUGoforms
OF_FILEAbsolute path to oforms.php
OF_DIRAbsolute path to plugin directory (trailing slash)
OF_URLURL to plugin directory (trailing slash)
03 · Plugin Structure

Plugin structure

oforms/ ├── oforms.php Bootstrap, constants, autoloader ├── uninstall.php Drops all tables on uninstall ├── assets/ │ ├── css/ │ │ ├── of-admin-v2.css Admin UI (dark/light tokens) │ │ └── form-v1.css Frontend form styles + themes │ └── js/ │ ├── admin-v1.js Builder, workflows, preview, theme toggle │ └── form-v1.js Validation, multi-step, reCAPTCHA, AJAX ├── blocks/oforms-embed/ │ ├── block.json Gutenberg block manifest │ └── index.js SelectControl + server-side render ├── includes/ │ ├── Admin/ │ │ ├── AdminMenu.php WP menu, script enqueue, POST routing │ │ └── Controllers/ │ │ ├── AnalyticsController.php │ │ ├── EntryController.php │ │ ├── FormController.php │ │ ├── SettingsController.php │ │ └── WorkflowController.php │ ├── Core/ │ │ ├── Autoloader.php PSR-4 autoloader │ │ ├── DB.php Static query helpers (insert/update/delete/get_row) │ │ ├── GdprManager.php Consent log + retention purges │ │ ├── Hooks.php Public extensibility surface (filters/actions reference) │ │ ├── Installer.php dbDelta for 10 tables + column migrations │ │ └── Plugin.php Boot sequence, block registration │ ├── Emails/ │ │ ├── EmailDispatcher.php Template variable replacement, brand HTML wrapper │ │ └── EmailQueue.php Async send via cron (30/min, retry x3) │ ├── Entries/EntryRepository.php CRUD + CSV + stats + receipt │ ├── Forms/ │ │ ├── FormRepository.php Form + field CRUD │ │ ├── FormTemplates.php 6 starter templates (contact, newsletter, etc.) │ │ ├── PartialSubmissionHandler.phpSave & continue + abandonment AJAX │ │ ├── ShortcodeHandler.php [oform] shortcode + preview handler │ │ └── SubmissionHandler.php AJAX, honeypot, reCAPTCHA, workflow trigger │ ├── Workflows/ │ │ ├── Queue.php Workflow action queue worker │ │ ├── WorkflowEngine.php Condition eval + 11 action dispatch │ │ ├── WorkflowRepository.php Workflow CRUD │ │ └── WebhookSubscriptions.php HMAC-signed outbound dispatcher │ ├── Integrations/ │ │ ├── CrmIntegration.php Abstract CRM provider │ │ ├── CrmManager.php Provider registry + dispatch │ │ ├── Mailchimp.php / HubSpot.php / Brevo.php │ │ ├── ActiveCampaign.php / ConvertKit.php │ │ ├── Elementor.php Elementor widget │ │ └── SmtpDetector.php Warns when no SMTP plugin is active │ ├── Analytics/Analytics.php Conversion + view rollups │ ├── Utils/XlsxWriter.php Native XLSX export (no PhpSpreadsheet) │ └── API/RestController.php REST endpoints (read + write, key auth) └── templates/ ├── form-render.php Frontend form HTML └── admin/ layout-header, form-list/edit/templates, entry-list/view, workflow-list/edit, analytics, settings
04 · Core Classes

Core classes

OForms\Core\Plugin

Singleton. Entry point called from oforms.php. boot() loads the text domain, runs DB migration, registers shortcode/submission/queue/REST, registers the Gutenberg block, boots the admin menu if is_admin(), then fires do_action('of_loaded').

PHPPlugin::get_instance()->boot();

OForms\Core\DB

Static $wpdb wrapper. All methods prepend {prefix}of_ to the table name automatically.

PHPDB::insert('entries', ['form_id' => 1, 'data' => '{}']);
DB::update('entries', ['status' => 'read'], ['id' => 42]);
DB::delete('entries', ['id' => 42]);
DB::get_row('forms', ['id' => 1]);
DB::get_results('entries', ['form_id' => 1], 'id DESC');

OForms\Forms\FormRepository

PHP$repo = new FormRepository();
$repo->all(200);
$repo->get(int $id);
$repo->get_fields(int $id);
$repo->create(string $name, array $settings): int
$repo->update(int $id, string $name, array $settings): void
$repo->save_fields(int $form_id, array $fields): void  // replaces all fields
$repo->delete(int $id): void
$repo->duplicate(int $id): int  // clones form + fields, appends " (Copy)"

OForms\Entries\EntryRepository

PHP$repo = new EntryRepository();
$repo->get(int $id);
$repo->for_form(int $form_id, int $limit, int $offset, string $status, string $search): array
$repo->count_for_form(int $form_id, string $status, string $search): int
$repo->create(int $form_id, array $data): int|false
$repo->update_status(int $id, string $status): void  // new|read|starred|spam|trash
$repo->update_email_status(int $id, string $status): void
$repo->delete(int $id): bool
$repo->get_stats_by_day(int $form_id, int $days = 30): array  // [{day, total}, ...]
$repo->export_csv(int $form_id): void  // streams CSV and exits
05 · Admin Interface

Admin interface

Full-viewport overlay below the WP admin bar (position: fixed; top: 32px; left: 0; right: 0; bottom: 0). No shared WP sidebar space. Three-row sticky header.

RowHeightSticky offsetContents
Brand bar50pxtop: 0Logo, version badge, user avatar, dark/light toggle
Nav strip44pxtop: 50pxTab links: Forms, Entries, Workflows, Analytics, Settings
Context bar~40pxtop: 94pxBreadcrumb + page-specific action buttons

Theme toggle saves preference to localStorage as oforms_theme. Dark is the default. Toggles of-light class on #of-wrap.

TabURL paramController
Formstab=formsFormController
Entriestab=entriesEntryController
Workflowstab=workflowsWorkflowController
Analyticstab=analyticsAnalyticsController
Settingstab=settingsSettingsController
06 · Field Types

23 field types

Standard input fields
text
<input type="text">
email
<input type="email">
Validated client + server
phone
<input type="tel">
number
<input type="number">
min / max / step config
url
<input type="url">
Validates https?:// prefix
textarea
<textarea>
Vertically resizable
dropdown
<select>
Options from config.options[]
radio
<input type="radio"> group
checkbox
<input type="checkbox"> group
Multi-select → array
date
<input type="date">
Native date picker
time
<input type="time">
file
<input type="file">
config.accept restricts MIME
range
<input type="range">
+ live value output
rating
SVG star group (CSS-only)
config.stars, default 5
Advanced / specialised fields
calc
Calculated read-only output
Live formula over numeric fields
signature
<canvas> signature pad
Saves PNG data URL into entry data
address
Composite address block
Country list filtered via of_address_country_list
repeater
Repeating sub-form rows
User adds/removes line items at runtime
gdpr_consent
Consent checkbox with audit
Logged via GdprManager for retention
Layout / content fields
heading
<h2>, <h3>, or <h4>
config.heading_level
html
<div> with wp_kses_post()
config.html_content
hidden
<input type="hidden">
config.default = value
step_break
Not rendered
Divides form into steps; config.label = step tab label

Field config JSON

JSON · of_form_fields.config{
  "label":              "Your Name",
  "placeholder":        "e.g. Jane Smith",
  "default":            "",
  "required":           true,
  "validation_message": "Please enter your full name.",
  "options":            ["Option A", "Option B"],
  "min":                0,
  "max":                100,
  "step":               1,
  "accept":             "jpg,png,pdf",
  "max_size":           2097152,
  "stars":              5,
  "heading_level":      "h3",
  "html_content":       "<p>Some <strong>HTML</strong></p>",
  "condition": {
    "field":    "42",
    "operator": "equals",
    "value":    "yes"
  }
}
07 · Form Themes

Four visual themes

Set via settings.theme. Applied as .of-theme--{theme} on .of-wrapper.

default
Default
Clean white, purple focus ring
classic
Classic
Georgia serif, parchment background, stacked borderless inputs
minimal
Minimal
Underline-only borders, uppercase labels, ghost submit button
card
Card
Boxed white card with shadow, rounded inputs, sky-blue accents
08 · Shortcode & Block

Shortcode & Gutenberg block

Shortcode

[oform id="42"]

Renders the form, enqueues form-v1.css and form-v1.js, and localises ofData to JS with the form's settings. The only parameter is id (required, form ID).

Gutenberg block

Block name: oforms/embed, registered via blocks/oforms-embed/block.json. The editor shows a SelectControl dropdown of all forms. Output is server-side rendered via do_shortcode('[oform id="X"]'); the save() function returns null.

ℹ️The block editor script requires the ofBlockForms global (array of {id, name} objects), localised by PHP during enqueue_block_editor_assets.
09 · Multi-Step Forms

Multi-step forms

Add one or more step_break fields. Fields before the first break are step 0; fields between breaks are subsequent steps.

How it works

LayerBehaviour
PHP (form-render.php)Loops fields. On step_break: increments $current_step, records step label. Groups fields into $steps[$step_index][].
HTML outputSteps render as <div class="of-step" data-step="N">; steps > 0 start hidden. Progress bar with fill width + labels shown above. Each step has a .of-step-nav with Back / Next (or Submit on the last step).
JS (form-v1.js)goToStep(n) hides all steps, shows step n, updates progress bar. Next calls validateStep() on visible fields only. Submit on the last step triggers full submission.

Progress bar classes

  • Active step label: .of-step-label--active
  • Completed steps: .of-step-label--done (green)
  • Fill width updates proportionally to current step
10 · Conditional Logic

Conditional logic

Any field can be shown or hidden based on another field's value. Set config.condition on the field to configure it.

JSON · config.condition{
  "field":    "42",
  "operator": "equals",
  "value":    "yes"
}

field is the field ID (not name). Rendered as data-condition='...' on the .of-field div.

Supported operators

OperatorLogic
equalsCase-insensitive exact match
not_equalsInverse of equals
containsSubstring match
not_containsInverse of contains
starts_withPrefix match
ends_withSuffix match
not_emptyField has any non-empty value
is_emptyField is blank or unset
greater_thanNumeric comparison (cast as float)
less_thanNumeric comparison (cast as float)

JS behaviour

On any change input event, all conditional fields are re-evaluated. Hidden fields get class .of-field--hidden and their inputs are disabled, preventing submission of hidden data. Validation also skips .of-field--hidden fields.

11 · Form Submissions

Submission flow

All submissions go through AJAX to admin-ajax.php?action=of_submit, available to both logged-in and logged-out users.

  • 1User submits form. AJAX POST to of_submit.
  • 2Honeypot check: _of_website field must be empty. Bots fill it; humans don't.
  • 3Nonce check: _of_nonce verified against of_submit_{form_id}.
  • 4reCAPTCHA check (if enabled): token POSTed to Google, score must be ≥ 0.5.
  • 5Field validation: required fields, email format, etc.
  • 6Entry saved via EntryRepository::create().
  • 7Workflows triggered via WorkflowEngine::process().
  • 8Response returned: { success: true, data: { message } } or { redirect: "..." } or { errors: { field_name: "msg" } }.

Entry data format

Stored in of_entries.data as JSON. Internal keys (prefixed with _) are hidden from the receipt and entry view but stored for workflow use.

JSON{
  "42": { "label": "Your Name",  "value": "Jane Smith" },
  "43": { "label": "Email",      "value": "jane@example.com" },
  "44": { "label": "Topics",     "value": ["Support", "Billing"] }
}
12 · Partial Entries

Save & continue, abandonment capture

Two AJAX endpoints persist in-progress work without creating an entry. Both stored in wp_of_partial_entries with a 7-day TTL.

Save & continue

Endpoint: admin-ajax.php?action=of_save_partial (logged-in or nopriv). Body: form_id, resume_token (optional), current_step, field_data (JSON). Returns the resume token. Resuming via ?of_resume=token rehydrates the form.

Abandonment capture

Endpoint: admin-ajax.php?action=of_log_abandonment. Fires on visibilitychange when the user leaves with at least one field touched. Stored with type = 'abandoned' for funnel analysis. Both partial and abandoned rows are pruned by the of_process_queue cron handler when expires_at passes.

13 · Workflows

Workflow automation

Workflows run automatically after every successful submission. Each workflow has one condition and one or more actions. Delayed actions are queued via WP-Cron.

Condition types

TypeDescription
alwaysRuns on every submission regardless of field values
fieldRuns only when a specific field's value matches the condition (same 10 operators as conditional logic)

Action types (11)

ActionConfig keysDescription
send_emailto, subject, bodyPushes onto wp_of_email_queue. Supports template variables: {all_fields}, {field_ID}, {form_name}, {date}, {site_name}
send_webhookurl, headers[]POSTs JSON payload with field labels + values, signed with HMAC-SHA256 if a secret is set
tag_entrytagAppends a tag string to data._tags array in the entry row
subscribe_omailerlist_id, email_field_id, name_field_idCalls omailer_subscribe() if OMailer is active; silently no-ops if not
create_wp_useremail_field_id, role, username_field_idCreates a WordPress user from form values; sends standard new-user email
create_postpost_type, title_field_id, content_field_id, statusCreates a CPT or post with mapped field values
send_slackwebhook_url, channel, textPosts to a Slack incoming webhook URL with merge-tag rendered text
send_discordwebhook_url, content, usernamePosts to a Discord webhook with rendered content
update_entry_fieldfield_id, valueMutates a value on the just-created entry (useful for normalisation)
duplicate_to_formtarget_form_id, field_mapCopies the entry into another form, mapping field IDs through field_map
crm_subscribeprovider, list_id, field_mapRoutes the entry through CrmManager (Mailchimp / HubSpot / Brevo / ActiveCampaign / ConvertKit)

Delayed actions

Any action can have a delay_seconds value. Delayed actions are stored in wp_of_queue and processed by the of_process_queue cron event, which fires every minute via the of_every_minute custom schedule. Failed actions retry up to 5 times with exponential backoff.

14 · Async Email Queue

Async email queue

When email_async is true, notifications are pushed onto wp_of_email_queue instead of sent inline. Median submission latency drops below 40ms because the response no longer blocks on SMTP.

  • Cron schedule: custom of_every_minute, fires of_process_email_queue
  • Rate limit: 30 emails per minute (constant RATE_PER_MINUTE in EmailQueue)
  • Retry policy: max 3 attempts before status flips to failed
  • SMTP detection: SmtpDetector warns in Settings if no SMTP plugin is active so transactional mail does not silently route through PHP mail()
  • Filters: of_email_before_send mutates args; of_send_admin_notification / of_send_user_confirmation short-circuit per submission
15 · Webhook Subscriptions

Webhook subscriptions

Persistent webhook endpoints managed under Settings or via REST. Stored in wp_of_webhook_subscriptions. Each subscription can be scoped to a single form (form_id) or fire across all forms (form_id = 0).

Signing

If a secret is set on the subscription, the dispatcher computes hash_hmac('sha256', $payload, $secret) and sends it in the X-OForms-Signature header. Receivers should reject any payload that fails to verify with hash_equals().

REST management

CRUD via /wp-json/of/v1/webhooks. Test a wired endpoint with POST /webhooks/{id}/test; the dispatcher fires a synthetic payload and returns the receiver's response code and latency.

16 · CRM Integrations

CRM integrations

Workflow action crm_subscribe routes entries through CrmManager. Five providers ship in-tree, all extending CrmIntegration:

  • OForms\Integrations\Mailchimp
  • OForms\Integrations\HubSpot
  • OForms\Integrations\Brevo
  • OForms\Integrations\ActiveCampaign
  • OForms\Integrations\ConvertKit

Register a custom provider with add_action('of_register_crm_integrations', fn($mgr) => $mgr::register(new MyCrm()));. Each integration declares its credential fields, which render automatically in Settings.

17 · Form Templates

Starter templates

Six prebuilt templates one click away under Forms → New from template. Source: OForms\Forms\FormTemplates::all().

contact
Contact
Name, email, subject, message
newsletter_signup
Newsletter
First name + email + opt-in
feedback
Feedback
Rating + comment
event_registration
Event registration
Attendee details + party size
support_request
Support request
Account, priority, issue
volunteer
Volunteer signup
Skills + availability
18 · Entries Management

Entries management

Status flow

Every entry moves through a lifecycle of statuses, shown as tabs across the top of the Entries list.

StatusBadgeMeaning
newNewFreshly submitted, unread
readReadViewed by admin
starredStarredMarked for attention
spamSpamMarked as spam
trashTrashSoft-deleted (still in DB)

Filtering, search & bulk actions

Filter by status using the tab links. Full-text search via LIKE %query% on the data column. Both can be combined. Bulk actions: Mark as Read, Mark as Spam, Delete (hard-delete from database).

14 · Analytics

Submission analytics

OForms → Analytics tab. Select a form and time range (7, 14, 30, or 90 days).

ComponentDescription
Stat cardsTotal submissions (all time) + submissions in the selected period
Bar chartCSS-only bar chart with one bar per day, proportional to the max daily count. Hover shows date and count.
Detail tableDaily breakdown in reverse chronological order

Data source: EntryRepository::get_stats_by_day(int $form_id, int $days), a single GROUP BY DATE(created_at) query returning [{day, total}, ...].

15 · reCAPTCHA v3

reCAPTCHA v3

Setup

  1. Go to OForms → Settings
  2. Enable reCAPTCHA v3
  3. Enter your Site Key and Secret Key from google.com/recaptcha

How it works

LayerBehaviour
FrontendGoogle reCAPTCHA v3 script loads. On submit: grecaptcha.execute(siteKey, { action: 'oforms_submit' }) is called. Returned token injected into hidden _of_recaptcha field before AJAX request.
BackendSubmissionHandler::verify_recaptcha() POSTs token to Google. If response.success === false or response.score < 0.5, submission is rejected.
Preview modereCAPTCHA is disabled (no site key passed to ofData) so forms can be tested without triggering Google scoring.
16 · CSV Export

CSV export

Available from Entries → Export CSV. Streamed directly to the browser via EntryController::export()EntryRepository::export_csv(). Limited to 10,000 rows per export.

HTTP headersContent-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="entries-form-42-2026-04-24.csv"

Columns: ID, Submitted At, Status, IP Address, Email Status, Data (pipe-separated Label: Value pairs).

18 · Form Preview

Form preview

The Preview button in the form editor opens a modal with a live <iframe> of the form. Admin-only, nonce-protected.

/?of_preview={form_id}&of_nonce={nonce}

Handler: ShortcodeHandler::maybe_preview(), which hooks into template_redirect. Checks $_GET['of_preview'], verifies manage_options capability and nonce (of_preview_{form_id}), renders a minimal standalone HTML page with the form and its CSS/JS, then calls exit.

💡reCAPTCHA is disabled in preview mode (no site key is passed to ofData) so you can test forms without Google scoring interference.
19 · Database Schema

Database schema

Ten custom tables, all prefixed with {prefix}of_. Created on activation via dbDelta(). Schema version stored in of_db_version; migrations run automatically when OF_VERSION changes (e.g. adds country + device_type columns to of_entries when upgrading).

of_forms

ColumnTypeDescription
idBIGINT UNSIGNED AI PKForm ID
nameVARCHAR(200)Form display name
settingsLONGTEXTJSON: submit label, success message, theme, redirect, reCAPTCHA
created_atDATETIMECreation timestamp
updated_atDATETIME ON UPDATELast modified

of_form_fields

ColumnTypeDescription
idBIGINT UNSIGNED AI PKField ID
form_idBIGINT UNSIGNEDFK → of_forms.id
typeVARCHAR(50)Field type slug
configLONGTEXTJSON field config (label, options, required, condition, etc.)
sort_orderINT DEFAULT 0Display order

of_entries

ColumnTypeDescription
idBIGINT UNSIGNED AI PKEntry ID
form_idBIGINT UNSIGNEDFK → of_forms.id
dataLONGTEXTJSON: { field_id: { label, value } }
ip_addressVARCHAR(45)Submitter IP (proxy-aware)
countryVARCHAR(2)ISO country derived during analytics rollup
device_typeVARCHAR(10)desktop / mobile / tablet
user_agentTEXTRaw browser UA
statusVARCHAR(20) DEFAULT 'new'new, read, starred, spam, trash
email_statusVARCHAR(20) DEFAULT 'pending'pending, sent, failed
created_atDATETIMESubmission timestamp

of_workflows & of_queue

TableKey columns
of_workflowsform_id, name, rules (JSON: condition + actions array), active (TINYINT)
of_queueworkflow_id, entry_id, action_data (JSON), status (pending/done/failed), attempts, scheduled_at, processed_at

of_partial_entries

ColumnTypeDescription
idBIGINT UNSIGNED AI PKPartial entry ID
form_idBIGINT UNSIGNEDFK → of_forms.id
tokenVARCHAR(64) UNIQUEResume URL token
dataLONGTEXTJSON of fields completed so far
stepSMALLINT UNSIGNEDCurrent multi-step index
typeVARCHAR(20)partial (Save & continue) or abandoned
expires_atDATETIMEDefault 7 days; pruned by cron

of_webhook_subscriptions

ColumnTypeDescription
form_idBIGINT UNSIGNED0 = all forms; otherwise scoped to one form
nameVARCHAR(255)Display name (e.g. "Zapier: New leads")
urlVARCHAR(2083)HTTPS endpoint that receives POSTs
secretVARCHAR(255)Optional HMAC-SHA256 signing secret
eventsVARCHAR(255)Comma list, default submission
activeTINYINT(1)Toggle without deleting

of_views

One row per form impression for conversion analytics: form_id, variant (a / b for split tests), session_id, ip_address, device_type, country, created_at. Joined with of_entries by form_id + date window to compute conversion rate.

of_email_queue

ColumnTypeDescription
entry_idBIGINT UNSIGNEDSource entry (0 if not entry-related)
form_idBIGINT UNSIGNEDSource form
recipient / subject / bodyVARCHAR / LONGTEXTEmail payload
headers / attachmentsTEXTNewline-separated
statusVARCHAR(20)pending, sent, failed
attemptsTINYINT UNSIGNEDMax 3, then marked failed
scheduled_at / sent_atDATETIMECron flushes at 30 messages / minute

of_api_keys

Authenticates REST calls without a logged-in admin cookie. Columns: name, api_key (UNIQUE, prefixed ofk_), created_at, last_used. Rotate keys from Settings; revoking deletes the row.

20 · REST API

REST API

Base namespace: of/v1. Read and write endpoints. Authenticate either with a logged-in admin cookie (manage_options) or an API key passed as X-OForms-Key: ofk_... header. Keys are managed in Settings and stored in wp_of_api_keys.

MethodEndpointDescription
GET/of/v1/formsList all forms
GET/of/v1/forms/{id}/fieldsField schema for a form
GET/of/v1/forms/{id}/entriesPaginated entries for a form
GET/of/v1/forms/{id}/entries/exportStreamed CSV / JSON export (limit, offset, status, search args)
GET/of/v1/forms/{id}/workflowsWorkflows attached to a form
POST/of/v1/forms/{id}/submitPublic submission endpoint, honeypot + reCAPTCHA enforced
GET / PUT / DELETE/of/v1/entries/{id}Read, update status, or delete a single entry
GET/of/v1/submissions/recentRecent entries across every form for dashboards
GET/of/v1/analytics/{form_id}Conversion analytics with view + entry rollups
GET / POST/of/v1/webhooksList or create webhook subscriptions
GET / PUT / PATCH / DELETE/of/v1/webhooks/{id}Manage a single webhook subscription
POST/of/v1/webhooks/{id}/testFire a test payload at the endpoint
GET / POST/of/v1/api-keysList or generate API keys
DELETE/of/v1/api-keys/{id}Revoke an API key
26 · API Keys

REST API keys

Authenticate REST calls without an admin cookie. Keys live in wp_of_api_keys with name, api_key, created_at, last_used.

  • Create: POST /wp-json/of/v1/api-keys with { "name": "zapier" } returns the key once; store it server-side
  • Use: pass header X-OForms-Key: ofk_live_... on any request
  • Audit: last_used updates on every authenticated call so leaks show up immediately
  • Revoke: DELETE /wp-json/of/v1/api-keys/{id} or remove from Settings
27 · Hooks & Filters

Hooks & filters

Actions

HookWhen firedArguments
of_loadedAfter all plugin components boot(none)
of_hooks_registeredAfter the Hooks class registers extension points(none)
of_entry_createdAfter an entry row is inserted into wp_of_entries$entry_id, $form_id, $data
of_workflow_actionAfter each workflow action runs$type, $action, $entry_id, $data
of_run_crm_actionRoutes crm_subscribe actions through CrmManager$action, $entry_id, $data
of_register_crm_integrationsLets addons register a custom CRM provider$manager (CrmManager)
of_process_queueCron event for workflow queue (every minute)(none)
of_process_email_queueCron event for the async email queue(none)

Filters

HookPurposeDefault
of_pre_submit_validateReject a submission with custom errors before saveWP_Error
of_field_typesAdd custom field types to the builder23 built-in types
of_workflow_actionsRegister custom workflow action types11 built-in actions
of_analytics_sectionsInject custom panels into the Analytics tabDefault rollups
of_send_admin_notificationSkip admin email per submissiontrue
of_send_user_confirmationSkip user confirmation emailtrue
of_email_before_sendMutate wp_mail args before dispatchArgs array
of_email_brand_colorBrand colour used in HTML email template#1A1040
of_address_country_listSupply / filter the ISO 3166 country list[] (host site provides)
of_is_proUnlock Pro features without a license serverdefined('OF_PRO_VERSION')

Custom workflow action via hook

PHPadd_action('of_workflow_action', function($type, $action, $entry_id, $data) {
    if ($type !== 'my_custom_action') return;
    // do something with the entry data
}, 10, 4);

OMailer integration

OForms detects OMailer by checking if omailer_subscribe() is defined. No explicit plugin dependency; if OMailer is not active, the subscribe_omailer action silently no-ops.

PHP · OMailer function signatureomailer_subscribe(int $list_id, string $email, string $name = ''): void
22 · Settings Reference

Settings reference

Global settings (wp_options key: of_settings)

KeyTypeDescription
from_namestringDefault From-name for outgoing emails
from_emailstringDefault From-address for outgoing emails
disable_cssboolSkip enqueuing form-v1.css for theme-driven styling
honeypot_enabledboolEnable the _of_website honeypot field on every form
recaptcha_enabledboolEnable reCAPTCHA v3 globally
recaptcha_site_keystringGoogle reCAPTCHA site key
recaptcha_secret_keystringGoogle reCAPTCHA secret key
email_asyncboolPush notifications onto wp_of_email_queue instead of inline send
data_retention_daysintDays to keep entries before GdprManager purges them (0 = never)

Other wp_options keys

KeyTypeDescription
of_db_versionstringTracks last-installed schema version; mismatch triggers Installer::install()

Per-form settings (of_forms.settings JSON)

KeyTypeDescription
submit_labelstringSubmit button text
success_messagestringShown after submission (HTML allowed)
success_typestringmessage or redirect
redirect_urlstringURL to redirect to on success
themestringdefault, classic, minimal, or card
_recaptcha_enabledboolOverride: enable reCAPTCHA for this form only
_recaptcha_site_keystringOverride: per-form reCAPTCHA key
23 · Extending OForms

Extending OForms

Adding a custom field type

  1. Add your type slug to the $field_types array in form-edit.php
  2. Add a rendering case in form-render.php (switch($type) block)
  3. Add a config UI case in appendField() in admin-v1.js
  4. Add serialisation logic in the form editor submit handler in admin-v1.js
  5. Add any validation logic in validateField() in form-v1.js

Adding a custom form theme

  1. Add your theme slug to $allowed in form-render.php
  2. Add .of-theme--{slug} CSS rules to form-v1.css
  3. Add the option to the theme <select> in form-edit.php

Adding a custom workflow action

  1. Add a case in WorkflowEngine::run_action()
  2. Add the option to the <select> in workflow-edit.php
  3. Handle the action config UI in appendAction() in admin-v1.js
  4. Handle serialisation in the workflow editor submit handler in admin-v1.js
24 · Security

Security model

ThreatMitigation
CSRF (admin)wp_nonce_field('of_admin') + check_admin_referer() on every admin POST
CSRF (frontend)Form submission includes _of_nonce verified with wp_verify_nonce()
Spam botsHoneypot field (_of_website, tabindex="-1", autocomplete="off") + optional reCAPTCHA v3
SQL injectionAll queries use $wpdb->prepare() with %d, %s placeholders
XSS (output)All output escaped with esc_html(), esc_attr(), esc_url(), wp_kses_post()
Privilege escalationEvery admin action checks current_user_can('manage_options')
File uploadsaccept attribute restricts MIME types client-side; server-side validation via WordPress media handling
Preview accessPreview URL requires valid nonce + manage_options capability
Receipt accessReceipt URL requires manage_options capability
reCAPTCHA bypassScore threshold of 0.5 enforced server-side; frontend token only
25 · Troubleshooting

Troubleshooting

Tables not created
Go to Settings → General and save. This triggers Installer::maybe_upgrade(). Alternatively, deactivate and reactivate the plugin.
Form not appearing
Confirm [oform id="X"] uses the correct form ID, the form has at least one active field, and jQuery is loaded by the theme.
Submissions not saving
Check the browser console for AJAX errors. Verify admin-ajax.php is accessible (some security plugins block it). Check the honeypot is not being filled by browser autofill; the field has tabindex="-1" and autocomplete="off".
reCAPTCHA rejecting all submissions
Verify site key and secret key match the registered domain in Google Console. Test with localhost (Google allows it for v3 but the score may be low). Confirm the reCAPTCHA v3 JS loads without CSP errors.
Workflows not firing
Check the workflow is set to Active. If a condition is set, verify the field ID matches a field on the same form. For delayed actions, confirm WP-Cron is functioning (wp cron event list via WP-CLI).
Preview shows a blank page
The preview URL requires the user to be logged in as admin. Verify the nonce has not expired (they last 24 hours). Confirm no caching plugin is intercepting the ?of_preview=X query string.
Analytics shows no data
Select a form from the dropdown. Confirm entries exist within the selected date range. Check the wp_of_entries table for rows with matching form_id.
Admin layout issues
The admin UI uses position: fixed to cover the viewport below the WP admin bar at top: 32px. If your admin bar height differs, update the top: 32px value in #of-wrap in of-admin-v2.css.
✦ Need help?

Got a question about OForms?

Reach out directly. Kenneth replies within 24 hours.