Home / integration / outbound-webhook-from-salesforce
outbound-webhook-from-salesforce
Use when Salesforce must POST a webhook to a third-party endpoint after a record change — with signed payloads, retries, dead-lettering, rate limits, and idempotency. Covers design choice between Outbound Message, Flow HTTP Callout, Apex Queueable callout, and Event Relay. NOT for receiving a webhook INTO Salesforce from an external system — use integration/webhook-inbound-patterns. NOT for the Workflow-triggered SOAP Outbound Message and its listener contract — use integration/outbound-messages-and-callbacks.
Open the skill folder on GitHub
Outbound Webhook From Salesforce
A webhook out of Salesforce is a callout with a delivery guarantee bolted on. Every design decision here follows from one platform fact and one distributed- systems fact, and teams that skip either build something that works in the sandbox and loses events in production.
The platform fact: "You can't make a callout when there are pending operations in the same transaction. Things that result in pending operations are DML statements, asynchronous Apex (such as future methods and batch Apex jobs), scheduled Apex, or sending email." A record change is a DML statement. So the callout cannot happen in the transaction that caused it — the only question is which async mechanism carries it.
The distributed-systems fact: the receiver can be slow, down, or lying about success. At-least-once delivery with idempotency is the only guarantee you can actually offer, and it has to be designed in, not added after the first incident.
| Concern | Established by | Fails when |
|---|---|---|
| The event escapes the transaction | Async boundary (Queueable, Platform Event, Scheduled Path) | The callout is attempted inline after DML |
| Delivery survives a bad hour | Retry with backoff + a durable outbox row | Retry is a for loop inside one transaction |
| The receiver can trust it | HMAC over a timestamped payload, secret in an External Credential | The secret is in Custom Metadata or in code |
| Nothing is processed twice | An idempotency key the receiver honours | You assume exactly-once because retries look rare |
| You find out when it breaks | Outbox status + a DLQ depth alert | Failures are logged and nobody reads logs |
Scope. This skill owns the producer side: picking the mechanism, shaping the payload, signing it, retrying it, and knowing when it is dead. Receiving a webhook into Salesforce is integration/webhook-inbound-patterns; verifying an inbound signature is integration/webhook-signature-verification. The Workflow-triggered SOAP Outbound Message and its listener WSDL contract belong to integration/outbound-messages-and-callbacks — it appears below only as one option in the mechanism choice.
Read standards/decision-trees/integration-pattern-selection.md (Direction 1, Q1–Q4) before choosing. This skill implements that tree's outcome; it does not re-derive it.
Before Starting
- Establish whether the receiver is idempotent. Ask, in writing, what
happens if they receive the same event twice. If the answer is "it would duplicate the order", the design needs an idempotency key they honour — and agreeing that is a contract negotiation, not a code change.
- Get the failure budget. "How many events may be lost, and how stale may a
delivery be?" A five-minute tolerance and a zero-loss requirement produce completely different designs. Nobody volunteers this; you have to ask.
- Classify the volume in events per minute at peak, not per day. A daily
average hides the mass update that generates 50,000 events in ninety seconds, and that burst is what the design has to survive.
- Decide who owns the secret and how it rotates. External Credential, not
Custom Metadata, not Apex source. Rotation is a Setup change if you get this right and a deployment if you do not.
- Find out whether ordering matters. If the receiver applies deltas rather
than absolute state, out-of-order delivery corrupts them — and every retrying design reorders. The cheap fix is to send absolute state plus a version number, and it is much cheaper to agree now.
Core Concepts
Callouts and the transaction boundary
The rule is absolute and it decides the architecture: a callout is illegal once the transaction has pending work. A trigger has, by definition, pending DML. So the shapes available are:
Record change ──► Trigger / Flow
│
├─► Queueable (Database.AllowsCallouts) ← the workhorse
├─► Platform Event ──► subscriber ──► callout
└─► After-save Flow + Scheduled Path (≥ 1 min) ──► HTTP Callout action
The relevant per-transaction ceilings, from the Apex Developer Guide:
| Limit | Value |
|---|---|
| Callouts per transaction | 100 |
| Cumulative callout timeout per transaction | 120 seconds |
| Default timeout per callout | 10 seconds |
| Configurable timeout per callout | 1 ms – 120,000 ms |
| Concurrent callouts outside the org's domain (Developer Edition) | 20 |
The 120-second cumulative budget is the one that bites. Twelve callouts at the 10-second default exhaust it, so a Queueable that loops over deliveries needs a bounded batch size and a per-callout timeout small enough that the batch fits.
The outbox is the design
Everything that makes this reliable — retry, backoff, dead-lettering, replay, observability — hangs off one durable row per delivery attempt. Without it, a retry is a loop inside a transaction that cannot outlive its own governor limits, and a failure is a debug log nobody reads.
Webhook_Delivery__c
Idempotency_Key__c External Id, Unique ← the whole idempotency story
Status__c Pending | Sent | Failed | Dead
Attempt_Count__c
Next_Attempt_At__c ← the sweeper's index
Payload__c Long Text
Last_Status_Code__c
Last_Error__c
Write the row in the same transaction as the record change (it is DML, which is legal there) and deliver it from a Queueable. The record change and the intent to deliver commit or roll back together; the delivery itself is asynchronous and retryable. That is the whole pattern.
Retry: a Finalizer, not a loop
System.Finalizer is the platform's answer to "the async job failed and I need to react". Attach one inside a Queueable's execute, and it runs afterwards whether or not the job threw:
System.attachFinalizer(Finalizer)— **"Only one finalizer instance can beattached to any Queueable job."**
FinalizerContext.getResult()returnsSystem.ParentJobResult—SUCCESSorUNHANDLED_EXCEPTION— andgetException()returns the exception in the second case.- **"A Queueable job that failed due to an unhandled exception can be
successively re-enqueued five times by a transaction finalizer."** The counter resets on success.
Five is a hard ceiling for finalizer-driven retry, which is exactly why long backoff schedules belong to a scheduled sweeper reading Next_Attempt_At__c rather than to finalizer chaining.
For the short end of the schedule, System.enqueueJob accepts a delay of 0–10 minutes and an AsyncOptions overload carrying MaximumQueueableStackDepth; System.AsyncInfo exposes getCurrentQueueableStackDepth() and getMaximumQueueableStackDepth() at runtime. Note the chaining constraint: "you can add only one job from an executing job. Only one child job can exist for each parent queueable job", and Developer Edition and Trial orgs cap the chained stack depth at 5.
Signing
HMAC-SHA256 over "{timestamp}.{body}" is the de-facto industry shape and the one receivers already have libraries for. Two Salesforce-specific points:
- The secret belongs in a Named Credential's External Credential, referenced
from the request with the documented merge syntax (
{!$Credential.<AuthProviderName>.<ParameterName>}and{!$Credential.Password}), so that rotation is a Setup change. "Salesforce manages all authentication for Apex callouts that specify a named credential as the callout endpoint so that your code doesn't have to." - Sign the exact bytes you send. If you serialize once for the signature and
again for the body, key order can differ and the receiver rejects everything. Serialize to a
Stringonce; sign that string; send that string.
The mirror-image skill for the receiving end is integration/webhook-signature-verification — read it to know what a competent receiver will check, because that is what you have to produce.
Mechanism comparison, with the numbers
| Mechanism | Async? | Retry | Auth | Signing | Verdict |
|---|---|---|---|---|---|
Apex Queueable + HttpClient | Yes | Yours: Finalizer + outbox sweeper | Named Credential, all protocols | Full control | Default for anything with a reliability requirement |
| Flow HTTP Callout | Only via Scheduled Path or async path | None built in | Named Credential (required) | Not practical declaratively | Low-volume, admin-owned, tolerant receivers |
| Platform Event + Apex subscriber | Yes, by construction | Subscriber-side | Named Credential | Full control | When several consumers want the same signal |
| Event Relay → Amazon EventBridge | Yes | AWS-side | Named Credential holding AWS account info | AWS-side | AWS-native fleets; not a way to call one HTTPS endpoint |
| Outbound Message (SOAP) | Yes | Platform: exponential backoff to 2 h, dropped at 24 h | Session ID / mTLS only | None | Legacy. Its host reached end of support 31 Dec 2025 |
Outbound Messaging's semantics are worth knowing precisely, because they are the bar the Apex design has to clear: "A single SOAP message can include up to 100 notifications"; "If a message can't be delivered, the interval between retries increases exponentially, up to a maximum of two hours between retries"; "messages stay in the queue until sent successfully, or until they're 24 hours old. After 24 hours, messages are dropped from the queue"; and — the one that surprises people — "Messages are retried independent of their order in the queue. As a result, messages can be delivered out of order."
Event Relay is a genuinely different shape rather than a webhook alternative. EventRelayConfig (API 56.0+, suffix .eventRelay) requires destinationResourceName — "the developer name of the named credential, which stores the AWS account information" — and an eventChannel. It relays platform events and change data capture events to Amazon EventBridge. If the requirement is "POST to a partner's HTTPS endpoint", this is the wrong tool; if it is "fan our events into an AWS estate", it removes a whole layer of your code.
Common Patterns
Pattern A — outbox + Queueable + Finalizer
The default. Trigger writes a Webhook_Delivery__c row and enqueues; the Queueable delivers a bounded batch; a Finalizer catches unhandled failures and re-enqueues up to the platform's five; a scheduled sweeper picks up anything with Next_Attempt_At__c in the past. Full implementation in