Inbound Webhook Processor
The inbound webhook processor is a generic, multi-tenant system for receiving and acting on HTTP webhook events from external providers. A single receiver endpoint handles all providers and tenants. Each registered webhook carries its own signature validation rules and processing logic, eliminating the need for provider-specific code or configuration files.
Architecture
The system separates receiving from processing. The receiver returns a 200 to the external provider immediately; a background job performs the heavier work asynchronously.
Receiver — Authenticates the incoming request by validating the signature according to the webhook's configured scheme, then enqueues a background job and returns immediately. Target latency is under 200 ms.
Processor — Picks up the background job, loads the full webhook configuration, evaluates filter rules against the payload, and executes matching actions. All results are written to the event log.
Data model
The processor uses three views:
| View | Purpose |
|---|---|
fnd_inbound_webhook_configurations | One row per registered webhook — identity, signature, rules |
fnd_inbound_webhook_logs | Request/response log for every received event |
fnd_inbound_webhook_subscription_logs | History of subscribe, unsubscribe, and resubscribe attempts |
Configuring a webhook
All per-webhook configuration is defined when you register a new webhook in fnd_inbound_webhook_configurations. A single row is all that is needed to register a new webhook endpoint — no code deployment or infrastructure change is required.
Core fields
| Field | Purpose |
|---|---|
tenant_id | Tenant the webhook belongs to |
webhook_id | Combined with tenant_id to form the URL path |
source_type | Provider category (e.g. DSM_ACCESS_APPLICATION, XRM_MERCHANT, GITLAB) |
source_record_id | The domain entity this webhook is linked to |
program_code | Identifies the processing program used when handling the event |
active | Set to FALSE to pause processing without deleting the configuration |
status | Current lifecycle state (see Subscription lifecycle) |
Signature validation fields
| Field | Purpose |
|---|---|
signature_check_enabled | Whether to reject requests that fail signature validation |
signature_scheme | One of NONE, TOKEN_HEADER, or HMAC_SHA256 |
signature_header_name | The HTTP header that carries the signature or token |
signature_credentials | The secret or token used for validation (e.g. {"secret": "..."}) |
Processing fields
| Field | Purpose |
|---|---|
processing_rules | Filter rulesets and actions to execute (see Processing rules) |
field_mapping_schema | Optional payload field remapping applied before rules are evaluated |
Subscription fields (optional)
Only relevant for webhooks that require active registration with the external provider.
| Field | Purpose |
|---|---|
subscription_credentials | Provider-specific credentials needed to subscribe |
provider_subscription_id | ID returned by the provider after subscribing |
subscribe_log | History of subscription events (see Subscription log) |
Signature validation
The receiver validates the incoming request according to signature_scheme before logging or enqueuing.
NONE
No validation is performed. The request is accepted as long as it reaches the correct (tenant_id, webhook_id) endpoint. Use for providers that do not support signatures, or during initial development.
TOKEN_HEADER
A static token is compared against the value of signature_header_name. If the header is missing or the values do not match, the request is rejected with 401.
signature_scheme: TOKEN_HEADER
signature_header_name: X-Gitlab-Token
signature_credentials: {"token": "s3cret-token-here"}
HMAC_SHA256
The receiver computes an HMAC-SHA256 digest over a concatenated string (typically timestamp + raw request body) and compares it to the value provided in signature_header_name. The exact concatenation format is configured in signature_credentials.
{
"secret": "shared-hmac-secret",
"format": "{timestamp}.{body}"
}
If the computed digest does not match, the request is rejected with 401.
Subscription lifecycle
Some external providers require explicit subscription calls before they start delivering events. The platform handles this automatically based on the webhook configuration.
Subscription statuses
A webhook that uses provider subscriptions moves through the following statuses:
| Status | Meaning |
|---|---|
PENDING | A subscribe request has been sent to the provider but no confirmation has been received yet. |
SUBSCRIBED | The provider confirmed the subscription. The webhook is actively receiving events. |
UNSUBSCRIBED | The subscription was cancelled successfully and the provider is no longer sending events. |
UNSUBSCRIBE_FAILED | An unsubscribe request was sent but the provider did not confirm cancellation. Manual follow-up may be needed. |
ERROR | The subscribe attempt failed. The webhook is not receiving events. Check the subscription log for details. |
ARCHIVED | The webhook has been permanently retired. No further subscribe or unsubscribe actions will be attempted. |
Receiver-only webhooks (e.g. providers that use a push model where you register the URL in their dashboard) skip subscription entirely. These are created directly with status = SUBSCRIBED.
State transitions
Subscribing
Registering a webhook with subscription_credentials set automatically triggers a subscribe request to the external provider. While the request is in progress, the status is PENDING. Once the provider confirms, the status moves to SUBSCRIBED and the provider_subscription_id is recorded.
If the subscribe attempt fails — for example, because the provider credentials are invalid or the provider is unreachable — the status moves to ERROR. No events will be delivered while the webhook is in this state.
Unsubscribing
Setting active = FALSE on a subscribed webhook triggers an unsubscribe request. The platform calls the provider to cancel the subscription. If the provider confirms, the status moves to UNSUBSCRIBED. If the provider does not confirm, the status moves to UNSUBSCRIBE_FAILED, indicating that the provider may still be sending events even though the webhook is deactivated locally. You should verify directly with the provider that the subscription was removed.
Resubscribing
A webhook in ERROR, UNSUBSCRIBED, or UNSUBSCRIBE_FAILED status can be resubscribed. Setting active = TRUE (or explicitly requesting a resubscribe) sends a new subscribe request to the provider and returns the status to PENDING. This is useful when a previous subscription expired, was cancelled, or failed due to a transient error.
Resubscribing creates a new subscription with the provider. A new provider_subscription_id is recorded on success.
Archiving
When a webhook is no longer needed, it can be archived. This moves the status to ARCHIVED permanently. No further subscribe or unsubscribe actions will be attempted, and the configuration is retained for audit purposes.
Subscription log
Each subscription event is recorded in the webhook's subscription log, providing a full history of subscribe, unsubscribe, and resubscribe activity.
Each log entry includes:
| Field | Description |
|---|---|
| Event type | The action that was attempted: SUBSCRIBE, UNSUBSCRIBE, or RESUBSCRIBE |
| Status | The outcome of the attempt: PENDING, SUCCESS, or FAILURE |
| Timestamp | When the event occurred |
| Details | Additional context such as provider response messages or error descriptions |
You can review the subscription log to diagnose connection issues, verify that a subscription was established, or confirm that an unsubscribe completed successfully. For example, if a webhook is in ERROR status, the most recent log entry will contain the reason the subscribe attempt failed.
Processing rules
processing_rules is an array of rulesets. Each ruleset has a filter (which events to act on) and a list of actions (what to do).
Filter rules
Filters use JSONPath expressions evaluated against the incoming payload. Multiple conditions can be combined with AND or OR logic.
{
"filter": {
"operator": "AND",
"conditions": [
{ "path": "$.event_type", "op": "eq", "value": "PAYMENT_RECEIVED" },
{ "path": "$.amount", "op": "gt", "value": 0 }
]
},
"actions": [...]
}
| Operator | Meaning |
|---|---|
eq | Equals |
ne | Not equals |
gt | Greater than |
lt | Less than |
contains | String or array contains value |
exists | Path resolves to a non-null value |
If no filter is provided, the ruleset matches every event.
Actions
Each action in the actions array describes an HTTP call the processor will make when the ruleset's filter matches.
{
"type": "HTTP_POST",
"url_template": "https://api.internal/v1/tenants/{{tenant_id}}/payments",
"headers": {
"Authorization": "Bearer {{cognito_token}}"
},
"body_template": "{\"external_id\": \"{{$.payment_id}}\", \"amount\": {{$.amount}}}"
}
Templates use Airspeed Velocity syntax. Variables available in templates include fields from the incoming payload (using JSONPath notation) and platform context values such as tenant_id and cognito_token.
| Action type | Description |
|---|---|
HTTP_GET | GET request to the resolved URL |
HTTP_POST | POST request with rendered body |
HTTP_PUT | PUT request with rendered body |
Duplicate detection
Before executing actions, the processor checks whether a previous event with the same payload has already been processed. If a match is found, the event is skipped and logged as a duplicate. This provides at-least-once delivery semantics with idempotent deduplication.
Example: registering a new webhook
The following example configures a webhook for an external payment provider that signs requests with HMAC-SHA256 and sends a PAYMENT_RECEIVED event. On receipt, the processor should call an internal endpoint to create a cash receipt.
1. Register the webhook
Create a new webhook configuration with the following settings:
{
"tenant_id": "9f3a1b2c-4d5e-6f78-90ab-cdef12345678",
"source_type": "XRM_MERCHANT",
"source_record_id": "merchant-uuid-here",
"program_code": "process_inbound_webhook",
"signature_check_enabled": true,
"signature_scheme": "HMAC_SHA256",
"signature_header_name": "X-Acme-Signature",
"signature_credentials": {
"secret": "my-shared-secret",
"format": "{timestamp}.{body}"
},
"processing_rules": [
{
"filter": {
"operator": "AND",
"conditions": [
{ "path": "$.event", "op": "eq", "value": "PAYMENT_RECEIVED" }
]
},
"actions": [
{
"type": "HTTP_POST",
"url_template": "https://api.internal/v1/tenants/{{tenant_id}}/far/cash-receipts",
"headers": { "Authorization": "Bearer {{cognito_token}}" },
"body_template": "{\"external_ref\": \"{{$.payment_id}}\", \"amount\": {{$.amount}}}"
}
]
}
],
"status": "SUBSCRIBED",
"active": true
}
Because this provider does not require an explicit subscription call, subscription_credentials is left empty and status is set directly to SUBSCRIBED.
2. The provider sends an event
The provider POSTs to /persist/v2/webhook_in/{tenant_id}/{webhook_id}. The receiver:
- Loads the configuration for the
(tenant_id, webhook_id)endpoint. - Validates the
X-Acme-Signatureheader using HMAC-SHA256. - Logs the event as
RECEIVED. - Enqueues a background job for processing.
- Returns
200to the provider.
3. The processor handles the event
The background job is picked up by the processor:
- Loads the full webhook configuration.
- Evaluates the filter:
$.event == "PAYMENT_RECEIVED"— matches. - Checks for a duplicate — none found.
- Renders the
HTTP_POSTaction template and calls the internal cash-receipt endpoint. - Logs the event as
PROCESSEDwith the action result. - Marks the job as
COMPLETED.
Related topics
- Job Scheduling and Notifications — the async dispatch pattern the receiver relies on
- Data Model Overview — common platform fields, audit tracking, and multi-tenancy
- Tenant Parameters — per-tenant configuration used alongside webhook config