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.
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.
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:
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 be
attached to any Queueable job."
FinalizerContext.getResult() returns System.ParentJobResult — SUCCESS or
UNHANDLED_EXCEPTION — and getException() 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 String once; 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
references/examples.md, Examples 1–3.
Pattern B — Platform Event as the fan-out point
When more than one consumer wants "an order closed", publish once and let
subscribers callout independently. Note the limit split: with Publish After
Commit, "Each method execution is counted as one DML statement against the Apex
DML statement limit"; with Publish Immediately, "Each method execution is
counted against a separate event publishing limit of 150 EventBus.publish()
calls". Limits.getPublishImmediateDML() reads the second.
Pattern C — Flow HTTP Callout for the low-volume, admin-owned case
Flow Builder generates an External Service registration and an invocable action
from the API's response shape, and requires an External Credential and a Named
Credential. It is a legitimate choice when volume is low, the receiver is
tolerant, and the integration should be owned by an admin — and it is the wrong
choice the moment a retry or a signature is required, because neither is
expressible declaratively.
Pattern D — notification-plus-pull
When the payload is large or sensitive, send an event that carries only an
identifier and let the receiver fetch the body over an authenticated API. This
removes payload size from your problem, removes PII from the outbox, and turns
"replay a delivery" into "they call again".
Pattern E — absolute state plus a version, never a delta
Because every retrying design reorders, {"status":"Closed","version":47} is
safe to apply twice and out of order; {"statusDelta":"+1"} is not. This is a
payload-design decision that buys you out of an entire class of production
incident, and it costs nothing at build time.
Decision Guidance
Situation
Approach
Any receiver with a reliability requirement
Outbox + Queueable + Finalizer + sweeper
Low volume, admin-owned, tolerant receiver
Flow HTTP Callout + Named Credential
Several internal consumers of the same signal
Platform Event, subscribers call out
Destination is an AWS estate
Event Relay → EventBridge
Existing Outbound Message
Migrate; its host hit end of support 31 Dec 2025
Receiver cannot deduplicate
Negotiate an idempotency key before writing code
Ordering matters to the receiver
Absolute state + version, or a single-threaded chain
Payload > a few hundred KB, or contains PII
Notification-plus-pull
Burst of 10,000+ events from a mass update
Outbox + sweeper with a bounded batch; never one Queueable per record
Receiver returns 429 with Retry-After
Honour the header; it overrides your backoff schedule
Receiver returns 4xx (not 408/429)
Dead-letter immediately; retrying a rejection wastes the budget
Secret rotation required
External Credential; the code never changes
Recommended Workflow
Route with the decision tree first. Read
standards/decision-trees/integration-pattern-selection.md, Direction 1,
Q1–Q4, and cite the branch that resolved the choice. Q4 in particular already
distinguishes transient-5xx retry, 429 throttling, idempotency keys, and
ordering sensitivity.
Design the outbox object before the sender: idempotency key as an External
Id, Unique field; status; attempt count; next-attempt timestamp; payload;
last status code and error. Everything else in this design hangs off it.
Write the delivery row in the triggering transaction and nothing else. No
callout there — the platform forbids it once DML is pending, and the failure
mode is a CalloutException in a trigger, which rolls back the user's save.
Deliver from a Queueable implementing Database.AllowsCallouts, with a
bounded batch sized against the 120-second cumulative timeout and an explicit
per-callout timeout. Use
templates/apex/HttpClient.cls —
it already carries Named Credential enforcement, timeouts, and transient
classification.
Attach a Finalizer for unhandled failures and keep the long backoff in a
scheduled sweeper. The finalizer path is capped at five successive
re-enqueues; the sweeper is not.
Sign the exact serialized string you send, with the secret in an External
Credential, and give the receiver the timestamp they need to bound replay.
Never log the payload or the signature.
Instrument the outbox, not the code path. Alert on DLQ depth and on
oldest-pending age. A dashboard of Status__c by hour answers "is it working"
without reading a single log, and a rising oldest-pending age is the earliest
signal that the receiver is degrading.
Review Checklist
No callout in any code path that runs after DML in the same transaction
Delivery intent is persisted in the triggering transaction, atomically with the change
Idempotency key is an External Id, Unique field, and is sent to the receiver
The receiver has confirmed, in writing, that they honour that key
Per-callout timeout is set explicitly, not left at the 10-second default
Retry distinguishes transient (5xx, 408, 429) from permanent (other 4xx)
Retry-After is honoured where the receiver sends it
Backoff is scheduled, not slept; no busy-wait inside a transaction
Finalizer re-enqueues are bounded, and the design knows the platform cap is five
Dead-letter state exists and is distinguishable from "still retrying"
Endpoint is callout:<NamedCredential>/...; no hostname in code
Signing secret is in an External Credential, never in Custom Metadata or source
The signed bytes and the sent bytes are the same String instance
Payload carries a schema version and a correlation id
Payload is absolute state with a version, not a delta
Nothing logs the payload, the signature, or any credential
Alerting exists on DLQ depth and on oldest-pending age
Tests use MockHttpResponseGenerator; no test touches a real endpoint
A mass-update burst has been modelled, not assumed
Salesforce-Specific Gotchas
Full detail in references/gotchas.md.
"You can't make a callout when there are pending operations" — the rule that shapes everything.
120 seconds cumulative per transaction, so twelve default-timeout callouts exhaust a Queueable.
Database.AllowsCallouts is a marker you have to remember, and forgetting it fails at runtime.
Only one finalizer per Queueable, and five successive finalizer re-enqueues.
Test.setMock plus DML ordering makes the callout-after-DML rule bite in tests too.
Platform Event publish behaviour changes which limit you consume.
A retried delivery is a reordered delivery — always, in every design.
HTTP 200 is not "processed" unless the receiver's contract says so.
Outbound Messages drop silently at 24 hours and deliver out of order by design.
Flow HTTP Callout has no retry, and adding one means leaving Flow.
Serializing twice produces two different strings and a signature the receiver rejects.
One Queueable per record does not survive a mass update.
Output Artifacts
Artifact
Description
Mechanism decision record
The chosen option, the decision-tree branch that produced it, and the volume/SLA/ordering facts behind it
Receiver contract note
Endpoint, auth, idempotency key semantics, signature scheme, what their 2xx means, and their documented rate limit
Outbox object
Webhook_Delivery__c with External Id, Unique idempotency key, status, attempt count, next-attempt time, payload, last error — plus a retention policy
Producer
Trigger or Flow writing the delivery row; Queueable with Database.AllowsCallouts delivering a bounded batch
Retry design
Backoff schedule, which status codes retry, Retry-After handling, finalizer role, and the dead-letter threshold
Named + External Credential
The endpoint and the signing secret, with a rotation runbook that requires no deployment
Payload schema
Versioned, absolute-state, with correlation id and idempotency key; documented for the receiver
Observability
Outbox status dashboard, DLQ depth alert, oldest-pending-age alert, and the replay procedure
Test suite
MockHttpResponseGenerator cases for 2xx, 5xx-then-success, 4xx-permanent, 429 with Retry-After, timeout, and a bulk burst
Related Skills
integration/webhook-signature-verification — the receiving end of the
signature you are producing; read it to know what a competent receiver checks
integration/webhook-inbound-patterns — the mirror direction, when the partner
is the one pushing to you
integration/outbound-messages-and-callbacks — the legacy SOAP Outbound
Message and its listener WSDL contract, if you are migrating one
integration/retry-and-backoff-patterns — the general backoff and
dead-lettering treatment this skill applies to webhooks specifically
integration/event-relay-configuration — Event Relay to Amazon EventBridge in
full, when the destination is an AWS estate rather than an HTTPS endpoint
apex/callout-limits-and-async-patterns — the per-transaction callout budget
and which async shape fits inside it
standards/decision-trees/integration-pattern-selection.md — Direction 1,
Q1–Q4: the routing this skill implements rather than re-derives
Related Templates
templates/apex/HttpClient.cls — Named-Credential-aware client with timeout,
transient classification, and retry already implemented
templates/apex/ApplicationLogger.cls — the queryable log this design's
observability depends on
templates/apex/tests/MockHttpResponseGenerator.cls — the only acceptable way
to test a callout
1---2name: outbound-webhook-from-salesforce3description: 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.4---56# Outbound Webhook From Salesforce78A webhook out of Salesforce is a callout with a delivery guarantee bolted on.9Every design decision here follows from one platform fact and one distributed-10systems fact, and teams that skip either build something that works in the11sandbox and loses events in production.1213**The platform fact:** *"You can't make a callout when there are pending14operations in the same transaction. Things that result in pending operations are15DML statements, asynchronous Apex (such as future methods and batch Apex jobs),16scheduled Apex, or sending email."* A record change is a DML statement. So the17callout cannot happen in the transaction that caused it — the only question is18which async mechanism carries it.1920**The distributed-systems fact:** the receiver can be slow, down, or lying about21success. At-least-once delivery with idempotency is the only guarantee you can22actually offer, and it has to be designed in, not added after the first incident.2324| Concern | Established by | Fails when |25|---|---|---|26| **The event escapes the transaction** | Async boundary (Queueable, Platform Event, Scheduled Path) | The callout is attempted inline after DML |27| **Delivery survives a bad hour** | Retry with backoff + a durable outbox row | Retry is a `for` loop inside one transaction |28| **The receiver can trust it** | HMAC over a timestamped payload, secret in an External Credential | The secret is in Custom Metadata or in code |29| **Nothing is processed twice** | An idempotency key the *receiver* honours | You assume exactly-once because retries look rare |30| **You find out when it breaks** | Outbox status + a DLQ depth alert | Failures are logged and nobody reads logs |3132**Scope.** This skill owns the *producer* side: picking the mechanism, shaping33the payload, signing it, retrying it, and knowing when it is dead. Receiving a34webhook into Salesforce is `integration/webhook-inbound-patterns`; verifying an35inbound signature is `integration/webhook-signature-verification`. The36Workflow-triggered SOAP Outbound Message and its listener WSDL contract belong to37`integration/outbound-messages-and-callbacks` — it appears below only as one38option in the mechanism choice.3940Read `standards/decision-trees/integration-pattern-selection.md` (Direction 1,41Q1–Q4) before choosing. This skill implements that tree's outcome; it does not42re-derive it.4344---4546## Before Starting47481. **Establish whether the receiver is idempotent.** Ask, in writing, what49 happens if they receive the same event twice. If the answer is "it would50 duplicate the order", the design needs an idempotency key *they honour* — and51 agreeing that is a contract negotiation, not a code change.52532. **Get the failure budget.** "How many events may be lost, and how stale may a54 delivery be?" A five-minute tolerance and a zero-loss requirement produce55 completely different designs. Nobody volunteers this; you have to ask.56573. **Classify the volume in events per minute at peak**, not per day. A daily58 average hides the mass update that generates 50,000 events in ninety seconds,59 and that burst is what the design has to survive.60614. **Decide who owns the secret and how it rotates.** External Credential, not62 Custom Metadata, not Apex source. Rotation is a Setup change if you get this63 right and a deployment if you do not.64655. **Find out whether ordering matters.** If the receiver applies deltas rather66 than absolute state, out-of-order delivery corrupts them — and every retrying67 design reorders. The cheap fix is to send absolute state plus a version68 number, and it is much cheaper to agree now.6970---7172## Core Concepts7374### Callouts and the transaction boundary7576The rule is absolute and it decides the architecture: a callout is illegal once77the transaction has pending work. A trigger has, by definition, pending DML. So78the shapes available are:7980```text81Record change ──► Trigger / Flow82 │83 ├─► Queueable (Database.AllowsCallouts) ← the workhorse84 ├─► Platform Event ──► subscriber ──► callout85 └─► After-save Flow + Scheduled Path (≥ 1 min) ──► HTTP Callout action86```8788The relevant per-transaction ceilings, from the Apex Developer Guide:8990| Limit | Value |91|---|---|92| Callouts per transaction | 100 |93| Cumulative callout timeout per transaction | 120 seconds |94| Default timeout per callout | 10 seconds |95| Configurable timeout per callout | 1 ms – 120,000 ms |96| Concurrent callouts outside the org's domain (Developer Edition) | 20 |9798The 120-second cumulative budget is the one that bites. Twelve callouts at the9910-second default exhaust it, so a Queueable that loops over deliveries needs a100bounded batch size and a per-callout timeout small enough that the batch fits.101102### The outbox is the design103104Everything that makes this reliable — retry, backoff, dead-lettering, replay,105observability — hangs off one durable row per delivery attempt. Without it, a106retry is a loop inside a transaction that cannot outlive its own governor limits,107and a failure is a debug log nobody reads.108109```text110Webhook_Delivery__c111 Idempotency_Key__c External Id, Unique ← the whole idempotency story112 Status__c Pending | Sent | Failed | Dead113 Attempt_Count__c114 Next_Attempt_At__c ← the sweeper's index115 Payload__c Long Text116 Last_Status_Code__c117 Last_Error__c118```119120Write the row in the same transaction as the record change (it is DML, which is121legal there) and deliver it from a Queueable. The record change and the122*intent to deliver* commit or roll back together; the delivery itself is123asynchronous and retryable. That is the whole pattern.124125### Retry: a Finalizer, not a loop126127`System.Finalizer` is the platform's answer to "the async job failed and I need128to react". Attach one inside a Queueable's `execute`, and it runs afterwards129whether or not the job threw:130131- `System.attachFinalizer(Finalizer)` — **"Only one finalizer instance can be132 attached to any Queueable job."**133- `FinalizerContext.getResult()` returns `System.ParentJobResult` — `SUCCESS` or134 `UNHANDLED_EXCEPTION` — and `getException()` returns the exception in the135 second case.136- **"A Queueable job that failed due to an unhandled exception can be137 successively re-enqueued five times by a transaction finalizer."** The counter138 resets on success.139140Five is a hard ceiling for finalizer-driven retry, which is exactly why long141backoff schedules belong to a scheduled sweeper reading `Next_Attempt_At__c`142rather than to finalizer chaining.143144For the short end of the schedule, `System.enqueueJob` accepts a delay of 0–10145minutes and an `AsyncOptions` overload carrying `MaximumQueueableStackDepth`;146`System.AsyncInfo` exposes `getCurrentQueueableStackDepth()` and147`getMaximumQueueableStackDepth()` at runtime. Note the chaining constraint:148"you can add only one job from an executing job. Only one child job can exist for149each parent queueable job", and Developer Edition and Trial orgs cap the chained150stack depth at 5.151152### Signing153154HMAC-SHA256 over `"{timestamp}.{body}"` is the de-facto industry shape and the155one receivers already have libraries for. Two Salesforce-specific points:156157- The secret belongs in a **Named Credential's External Credential**, referenced158 from the request with the documented merge syntax159 (`{!$Credential.<AuthProviderName>.<ParameterName>}` and160 `{!$Credential.Password}`), so that rotation is a Setup change. "Salesforce161 manages all authentication for Apex callouts that specify a named credential as162 the callout endpoint so that your code doesn't have to."163- Sign the **exact bytes you send**. If you serialize once for the signature and164 again for the body, key order can differ and the receiver rejects everything.165 Serialize to a `String` once; sign that string; send that string.166167The mirror-image skill for the receiving end is168`integration/webhook-signature-verification` — read it to know what a competent169receiver will check, because that is what you have to produce.170171### Mechanism comparison, with the numbers172173| Mechanism | Async? | Retry | Auth | Signing | Verdict |174|---|---|---|---|---|---|175| **Apex Queueable + `HttpClient`** | Yes | Yours: Finalizer + outbox sweeper | Named Credential, all protocols | Full control | Default for anything with a reliability requirement |176| **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 |177| **Platform Event + Apex subscriber** | Yes, by construction | Subscriber-side | Named Credential | Full control | When several consumers want the same signal |178| **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 |179| **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 |180181Outbound Messaging's semantics are worth knowing precisely, because they are the182bar the Apex design has to clear: "A single SOAP message can include up to 100183notifications"; "If a message can't be delivered, the interval between retries184increases exponentially, up to a maximum of two hours between retries"; "messages185stay in the queue until sent successfully, or until they're 24 hours old. After18624 hours, messages are dropped from the queue"; and — the one that surprises187people — "Messages are retried independent of their order in the queue. As a188result, messages can be delivered out of order."189190Event Relay is a genuinely different shape rather than a webhook alternative.191`EventRelayConfig` (API 56.0+, suffix `.eventRelay`) requires192`destinationResourceName` — "the developer name of the named credential, which193stores the AWS account information" — and an `eventChannel`. It relays platform194events and change data capture events to Amazon EventBridge. If the requirement195is "POST to a partner's HTTPS endpoint", this is the wrong tool; if it is "fan196our events into an AWS estate", it removes a whole layer of your code.197198---199200## Common Patterns201202### Pattern A — outbox + Queueable + Finalizer203204The default. Trigger writes a `Webhook_Delivery__c` row and enqueues; the205Queueable delivers a bounded batch; a Finalizer catches unhandled failures and206re-enqueues up to the platform's five; a scheduled sweeper picks up anything with207`Next_Attempt_At__c` in the past. Full implementation in208[`references/examples.md`](references/examples.md), Examples 1–3.209210### Pattern B — Platform Event as the fan-out point211212When more than one consumer wants "an order closed", publish once and let213subscribers callout independently. Note the limit split: with **Publish After214Commit**, "Each method execution is counted as one DML statement against the Apex215DML statement limit"; with **Publish Immediately**, "Each method execution is216counted against a separate event publishing limit of 150 `EventBus.publish()`217calls". `Limits.getPublishImmediateDML()` reads the second.218219### Pattern C — Flow HTTP Callout for the low-volume, admin-owned case220221Flow Builder generates an External Service registration and an invocable action222from the API's response shape, and requires an External Credential and a Named223Credential. It is a legitimate choice when volume is low, the receiver is224tolerant, and the integration should be owned by an admin — and it is the wrong225choice the moment a retry or a signature is required, because neither is226expressible declaratively.227228### Pattern D — notification-plus-pull229230When the payload is large or sensitive, send an event that carries only an231identifier and let the receiver fetch the body over an authenticated API. This232removes payload size from your problem, removes PII from the outbox, and turns233"replay a delivery" into "they call again".234235### Pattern E — absolute state plus a version, never a delta236237Because every retrying design reorders, `{"status":"Closed","version":47}` is238safe to apply twice and out of order; `{"statusDelta":"+1"}` is not. This is a239payload-design decision that buys you out of an entire class of production240incident, and it costs nothing at build time.241242---243244## Decision Guidance245246| Situation | Approach |247|---|---|248| Any receiver with a reliability requirement | Outbox + Queueable + Finalizer + sweeper |249| Low volume, admin-owned, tolerant receiver | Flow HTTP Callout + Named Credential |250| Several internal consumers of the same signal | Platform Event, subscribers call out |251| Destination is an AWS estate | Event Relay → EventBridge |252| Existing Outbound Message | Migrate; its host hit end of support 31 Dec 2025 |253| Receiver cannot deduplicate | Negotiate an idempotency key before writing code |254| Ordering matters to the receiver | Absolute state + version, or a single-threaded chain |255| Payload > a few hundred KB, or contains PII | Notification-plus-pull |256| Burst of 10,000+ events from a mass update | Outbox + sweeper with a bounded batch; never one Queueable per record |257| Receiver returns 429 with `Retry-After` | Honour the header; it overrides your backoff schedule |258| Receiver returns 4xx (not 408/429) | Dead-letter immediately; retrying a rejection wastes the budget |259| Secret rotation required | External Credential; the code never changes |260261---262263## Recommended Workflow2642651. **Route with the decision tree first.** Read266 `standards/decision-trees/integration-pattern-selection.md`, Direction 1,267 Q1–Q4, and cite the branch that resolved the choice. Q4 in particular already268 distinguishes transient-5xx retry, 429 throttling, idempotency keys, and269 ordering sensitivity.2702. **Design the outbox object before the sender**: idempotency key as an External271 Id, Unique field; status; attempt count; next-attempt timestamp; payload;272 last status code and error. Everything else in this design hangs off it.2733. **Write the delivery row in the triggering transaction and nothing else.** No274 callout there — the platform forbids it once DML is pending, and the failure275 mode is a `CalloutException` in a trigger, which rolls back the user's save.2764. **Deliver from a Queueable implementing `Database.AllowsCallouts`**, with a277 bounded batch sized against the 120-second cumulative timeout and an explicit278 per-callout timeout. Use279 [`templates/apex/HttpClient.cls`](../../../templates/apex/HttpClient.cls) —280 it already carries Named Credential enforcement, timeouts, and transient281 classification.2825. **Attach a `Finalizer` for unhandled failures** and keep the long backoff in a283 scheduled sweeper. The finalizer path is capped at five successive284 re-enqueues; the sweeper is not.2856. **Sign the exact serialized string you send**, with the secret in an External286 Credential, and give the receiver the timestamp they need to bound replay.287 Never log the payload or the signature.2887. **Instrument the outbox, not the code path.** Alert on DLQ depth and on289 oldest-pending age. A dashboard of `Status__c` by hour answers "is it working"290 without reading a single log, and a rising oldest-pending age is the earliest291 signal that the receiver is degrading.292293---294295## Review Checklist296297- [ ] No callout in any code path that runs after DML in the same transaction298- [ ] Delivery intent is persisted in the triggering transaction, atomically with the change299- [ ] Idempotency key is an External Id, Unique field, and is sent to the receiver300- [ ] The receiver has confirmed, in writing, that they honour that key301- [ ] Queueable implements `Database.AllowsCallouts`302- [ ] Batch size × per-callout timeout fits inside the 120-second cumulative budget303- [ ] Per-callout timeout is set explicitly, not left at the 10-second default304- [ ] Retry distinguishes transient (5xx, 408, 429) from permanent (other 4xx)305- [ ] `Retry-After` is honoured where the receiver sends it306- [ ] Backoff is scheduled, not slept; no busy-wait inside a transaction307- [ ] Finalizer re-enqueues are bounded, and the design knows the platform cap is five308- [ ] Dead-letter state exists and is distinguishable from "still retrying"309- [ ] Endpoint is `callout:<NamedCredential>/...`; no hostname in code310- [ ] Signing secret is in an External Credential, never in Custom Metadata or source311- [ ] The signed bytes and the sent bytes are the same `String` instance312- [ ] Payload carries a schema version and a correlation id313- [ ] Payload is absolute state with a version, not a delta314- [ ] Nothing logs the payload, the signature, or any credential315- [ ] Alerting exists on DLQ depth **and** on oldest-pending age316- [ ] Tests use `MockHttpResponseGenerator`; no test touches a real endpoint317- [ ] A mass-update burst has been modelled, not assumed318319---320321## Salesforce-Specific Gotchas322323Full detail in [`references/gotchas.md`](references/gotchas.md).3243251. **"You can't make a callout when there are pending operations"** — the rule that shapes everything.3262. **120 seconds cumulative per transaction**, so twelve default-timeout callouts exhaust a Queueable.3273. **`Database.AllowsCallouts` is a marker you have to remember**, and forgetting it fails at runtime.3284. **Only one finalizer per Queueable**, and five successive finalizer re-enqueues.3295. **`Test.setMock` plus DML ordering** makes the callout-after-DML rule bite in tests too.3306. **Platform Event publish behaviour changes which limit you consume.**3317. **A retried delivery is a reordered delivery** — always, in every design.3328. **HTTP 200 is not "processed"** unless the receiver's contract says so.3339. **Outbound Messages drop silently at 24 hours** and deliver out of order by design.33410. **Flow HTTP Callout has no retry**, and adding one means leaving Flow.33511. **Serializing twice produces two different strings** and a signature the receiver rejects.33612. **One Queueable per record does not survive a mass update.**337338---339340## Output Artifacts341342| Artifact | Description |343|---|---|344| Mechanism decision record | The chosen option, the decision-tree branch that produced it, and the volume/SLA/ordering facts behind it |345| Receiver contract note | Endpoint, auth, idempotency key semantics, signature scheme, what their 2xx means, and their documented rate limit |346| Outbox object | `Webhook_Delivery__c` with External Id, Unique idempotency key, status, attempt count, next-attempt time, payload, last error — plus a retention policy |347| Producer | Trigger or Flow writing the delivery row; Queueable with `Database.AllowsCallouts` delivering a bounded batch |348| Retry design | Backoff schedule, which status codes retry, `Retry-After` handling, finalizer role, and the dead-letter threshold |349| Named + External Credential | The endpoint and the signing secret, with a rotation runbook that requires no deployment |350| Payload schema | Versioned, absolute-state, with correlation id and idempotency key; documented for the receiver |351| Observability | Outbox status dashboard, DLQ depth alert, oldest-pending-age alert, and the replay procedure |352| Test suite | `MockHttpResponseGenerator` cases for 2xx, 5xx-then-success, 4xx-permanent, 429 with `Retry-After`, timeout, and a bulk burst |353354---355356## Related Skills357358- `integration/webhook-signature-verification` — the receiving end of the359 signature you are producing; read it to know what a competent receiver checks360- `integration/webhook-inbound-patterns` — the mirror direction, when the partner361 is the one pushing to you362- `integration/outbound-messages-and-callbacks` — the legacy SOAP Outbound363 Message and its listener WSDL contract, if you are migrating one364- `integration/retry-and-backoff-patterns` — the general backoff and365 dead-lettering treatment this skill applies to webhooks specifically366- `integration/event-relay-configuration` — Event Relay to Amazon EventBridge in367 full, when the destination is an AWS estate rather than an HTTPS endpoint368- `apex/callout-limits-and-async-patterns` — the per-transaction callout budget369 and which async shape fits inside it370- `standards/decision-trees/integration-pattern-selection.md` — Direction 1,371 Q1–Q4: the routing this skill implements rather than re-derives372373## Related Templates374375- `templates/apex/HttpClient.cls` — Named-Credential-aware client with timeout,376 transient classification, and retry already implemented377- `templates/apex/ApplicationLogger.cls` — the queryable log this design's378 observability depends on379- `templates/apex/tests/MockHttpResponseGenerator.cls` — the only acceptable way380 to test a callout
Run npx skillmds add pranavnagrecha/outbound-webhook-from-salesforce in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
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. It is listed under Integrations & APIs on SkillMD.
This skill has not completed SkillMD's automated safety review yet. Capability flags: executes scripts, makes network calls, reads secrets. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free, and the skill stays under its author's original license.
PranavNagrecha (@pranavnagrecha) published this skill. Their other Agent Skills are listed on their SkillMD profile.