Webhooks fire a JSON payload to one or more URLs on every successful submission. Supports custom webhooks, Slack, Discord, and Telegram. PRO feature.
Payload Format
{
"form_id": 1,
"form_title": "Contact Form",
"submission_id": 42,
"timestamp": "2025-01-20T14:35:22+00:00",
"fields": {
"field_1": { "label": "Full Name", "value": "John Doe" },
"field_2": { "label": "Email", "value": "[email protected]" }
}
}SSRF Protection
The webhook URL goes through is_safe_url() before any request:
- Only
http://andhttps://schemes are allowed localhost,127.0.0.1,::1,0.0.0.0are blocked- DNS resolution is checked — no private or reserved IP ranges
filter_var()withFILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
Delivery
Webhooks use wp_remote_post() with Content-Type: application/json, 10-second timeout, and blocking delivery so Form Forge can capture the HTTP status or WP_Error. The visitor’s submission is still saved first; a webhook failure does not roll back the saved row. Recent attempts are stored in the capped formforge_webhook_log option with status, message, form_id, submission_id, URL host, and timestamp.
Optional custom headers can be stored in form settings as webhook_headers, one Name: Value pair per line. Header names are accepted only when they match the HTTP token format; empty or malformed lines are ignored.
Slack Webhook
Native support via Block Kit formatting. Set slack_webhook_url in form settings:
Runtime delivery requires an exact HTTPS Slack incoming-webhook URL: host hooks.slack.com and path starting /services/. Older saved values that only contain that text inside another host are ignored.
Empty field values and internal/system keys are skipped, populated fields are sent in section.fields chunks of up to 10 items to match Slack’s Block Kit limits, and Stripe metadata is normalized into Payment status / Payment amount / Payment date rows.
{
"blocks": [
{
"type": "header",
"text": {"type": "plain_text", "text": "New submission: Contact Form"}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": "*Full Name:*nJohn Doe"},
{"type": "mrkdwn", "text": "*Email:*[email protected]"}
]
}
]
}Discord Webhook
Native support using embed format. Set discord_webhook_url in settings.
Runtime delivery requires an exact HTTPS Discord webhook URL: host discord.com and path starting /api/webhooks/. This same check runs again at delivery time, not only when the settings form is saved.
Telegram
Telegram notifications go through the Forge API bot. Users only need their Chat ID:
{
"settings": {
"telegram_enabled": true,
"telegram_chat_id": "123456789"
}
}The request includes product=formforge, chat_id, normalized submitted fields, and the connected Form Forge license key. Normalized fields are built with FORMFORGE_Value_Formatter::submission_display_fields(), so _payment / __payment bags never leak into Telegram, Slack, Discord, email, CSV, or webhook-facing payloads. The runtime first checks shared Forge Connect license data and then falls back to the plugin API client’s saved license key.
The Forge API sends Telegram with parse_mode=MarkdownV2, so every dynamic value must be escaped before it reaches sendMessage: form title, form ID, submission ID, site URL, field labels, field values, and timestamp. Per-form telegram_chat_id is an override only; if it is empty, the plugin uses the global formforge_telegram_chat_id option.
WhatsApp notifications go through the Forge API and the approved form_submission WhatsApp Business template. Form Forge > Settings > WhatsApp stores the admin-entered recipient phone in formforge_settings.whatsapp_phone_e164; the settings UI and save handler allow only digits plus one optional leading +, and runtime delivery normalizes that value to digits with country code before calling the Worker. formforge_settings.whatsapp_global_enabled is the global delivery kill switch; missing means enabled for backwards compatibility, and 0 skips WhatsApp delivery before license or network work.
Form Forge sends both site_url and forge_site_url on WhatsApp delivery, plus the signed site capability token. The Worker validates the Form Forge license and the site token before it accepts a direct recipient phone:
{
"license_key": "sk_live_...",
"product": "formforge",
"site_url": "https://example.com/",
"forge_site_url": "https://example.com/",
"site_token": "...",
"recipient_phone_e164": "998901234567"
}Runtime submission delivery calls /notifications/whatsapp with recipient_phone_e164, form_title, submitter, submission_time, and template_lang. A successful response includes ok, message_id, message_status, and to; the Worker also stores accepted sends and later WhatsApp webhook delivery statuses in whatsapp_message_events for support diagnostics. If recipient_phone_e164 is missing, the Worker still falls back to the legacy phone-map lookup for backwards compatibility with older plugin builds. If the same /notifications/whatsapp endpoint receives a Meta webhook envelope (object=whatsapp_business_account with message/status changes), the Worker routes it through the webhook handler before plugin-send authentication. /notifications/whatsapp/webhook remains the canonical callback URL, and the shorter /notifications/whatsapp URL is accepted as a compatibility alias for Meta verification and inbound webhooks. If the recipient phone is missing or invalid, submission saving still succeeds and the failure is logged instead of blocking the visitor.
The older registration endpoints remain available for legacy builds and support diagnostics. /notifications/whatsapp/register-token deletes older pending tokens for the same normalized site before inserting the new 10-minute token. /notifications/whatsapp/status returns both the current phone map and the active pending token state:
{
"ok": true,
"registered": true,
"phone_e164": "998901111111",
"registered_at": "2026-05-16 09:00:00",
"pending": true,
"pending_token": "abcdef1234567890abcdef1234567890",
"pending_expires_at": "2026-05-16 10:10:00"
}Current admin UI no longer depends on that /start registration state for normal setup. The WhatsApp webhook still matches malformed /start attempts first and sends explicit replies for missing code, invalid format, expired code, and not-found/already-used code. Each /start outcome is stored with an event type such as register_connected, register_expired, register_not_found, or register_invalid_format, so support can tell whether Meta delivered the inbound webhook when testing the legacy path.
—