Accept inbound webhooks (Stripe, GitHub, Slack, partner) and verify HMAC signatures in Apex REST — Crypto.verifyHMac platform verification, secret in Protected Custom Metadata, replay-window rejection. NOT for designing the receiver end to end — Sites routing, guest-user access, idempotency, the 5-second response window — use integration/webhook-inbound-patterns. NOT for signing a webhook Salesforce sends out — use integration/outbound-webhook-from-salesforce.
An inbound webhook is an HTTP POST from a third party that Salesforce did not
initiate and cannot authenticate by session. When the endpoint is exposed through
a public Salesforce Site, the signature check is the entire authentication
boundary — there is no user, no token, and no network control in front of it.
Three properties have to hold, and each fails independently:
Property
Established by
Fails when
Authenticity — the sender holds the shared secret
HMAC over the payload
Secret is wrong, or the wrong encoding of the right secret
Integrity — the bytes were not altered in transit
HMAC over the raw body
The body was parsed and re-serialised before hashing
Freshness — this is not a replay of an earlier valid request
Timestamp tolerance + idempotency key
Neither is implemented — the signature alone never establishes this
Most broken webhook endpoints have the first two and not the third.
Scope. This skill owns the verification step: which Apex crypto method to
call, over which bytes, against which secret, and what to reject. Designing the
receiver end to end — Sites routing, guest-user access model, the idempotency
schema, the response-window budget — belongs to
integration/webhook-inbound-patterns. Those subjects appear below only as far as
they change the verification decision (for example: on a public Site the signature
is the only gate, which raises the stakes on every gotcha here). Signing a webhook
Salesforce sends is integration/outbound-webhook-from-salesforce.
Before Starting
Identify the signature scheme. HMAC (shared secret) or asymmetric
(RSA/ECDSA against a published public key)? They need different Apex methods
and different secret storage. Do not assume HMAC.
Get the exact header name, value format, and signed payload. Not every
provider signs the body alone — Stripe signs "{timestamp}.{body}". Getting
this wrong produces 100% verification failure that looks like a key problem.
Decide how the request reaches Apex./services/apexrest/* requires an
authenticated session, which a webhook does not have. Either the provider
obtains a token (Connected App, client credentials flow) or you expose the class
through a Salesforce Site guest user. Pick before you write the class.
Choose where the secret lives. Protected Custom Metadata, read via
getAll(). Not a literal, not a Custom Setting, not a Named Credential (which
is for outbound).
Crypto.generateMac produces a MAC and is correct for building a signature (in
tests, or when calling out). It is the wrong tool for checking one, because it
forces you to write the comparison yourself — and String.equals short-circuits
on the first differing character. Both Stripe and GitHub explicitly tell
integrators to compare signatures in constant time.
What the Apex Reference Guide does and does not say. Its verifyHMac entry
documents the signature, the four valid algorithm names, the 4 KB key cap, the
Base64 symmetry rule, and the Boolean return. It makes no timing claim. So
verifyHMac is the recommended method here because it keeps the comparison out
of your code entirely, not because Salesforce documents it as constant-time —
that property is undocumented, and this skill does not assert it. Detail in
references/gotchas.md, Gotcha 4.
with the provider's public certificate stored in Setup → Certificate and Key
Management, so rotation is a Setup change rather than a deploy.
The Base64 symmetry rule
"You may supply a private key that has been encoded using Base64 encoding.
However if you do, then you must also supply the Base64-encoded private key when
verifying the MAC using the verifyHMac method."
— Apex Reference Guide, Crypto.generateMac
Record in a comment which form the provider uses. Stripe's whsec_... is an
opaque ASCII string used raw; some providers issue a Base64 blob that must be
decoded first.
Raw body only
RestRequest.requestBody is a Blob — the exact bytes the sender hashed.
JSON.serialize(JSON.deserializeUntyped(...)) reorders keys, strips whitespace,
and normalises numbers, so a MAC computed over the round trip never matches. Verify
first; parse second.
Headers are case-insensitive; Apex maps are not
RestRequest.headers is a Map<String, String> with exact-match lookup. Proxies
and provider clients normalise header casing differently, so
headers.get('Stripe-Signature') can return null in production while working in
a test that used the same spelling. Always look up case-insensitively.
Size ceiling
"The maximum request or response size is 6 MB for synchronous Apex or 12 MB for
asynchronous Apex." A provider posting near that ceiling has outgrown the webhook
shape; move to notification-plus-pull.
Common Patterns
Pattern A — verify, stage, return 200, process async
The default. The resource verifies, upserts the raw event against an External Id,
enqueues a Queueable, and returns. Providers time out in single-digit seconds and
retry on timeout; anything slower turns one event into repeated deliveries. Full
implementation in references/examples.md, Example 2.
Pattern B — replay window before HMAC
If the provider signs a timestamp, check it first. A replay flood then costs a
Long comparison rather than a crypto operation. Stripe's libraries "have a
default tolerance of 5 minutes between the timestamp and the current time," and
Stripe warns against a tolerance of 0 because it disables the recency check
entirely.
Pattern C — accept multiple signatures during rotation
Stripe emits one signature per active secret while a secret roll is in flight
(previous secret active for up to 24 hours), so a verifier that loops over every
v1= value rides the rotation with no downtime. Where the provider does not do
this, hold Secret__c and Previous_Secret__c on the metadata record and try
both, removing the old value in a separate, dated change.
Pattern D — layer IP allowlisting where the provider publishes ranges
Stripe publishes its webhook source IPs and recommends allowlisting them in
addition to signature verification. This is defence in depth, not a replacement:
IP ranges change, and an allowlist alone proves nothing about payload integrity.
Crypto.verify(alg, data, sig, certDevName) with the cert in Certificate and Key Management
Provider can send a bearer token
Connected App + OAuth 2.0 client credentials flow, and keep the signature check
Provider cannot authenticate at all
Public Site + guest-user Apex class access; the HMAC check is the only gate
Provider signs a derived payload
Reconstruct the derivation from the raw body string, never from a re-serialised object
Payload approaches 6 MB
Notification-plus-pull: the event carries an id, Salesforce fetches the body
Provider offers several signature schemes
Pin one in code; never let the sender choose the algorithm
Recommended Workflow
Read the provider's signature documentation and write down four things:
header name, value grammar, exactly which bytes are signed, and the algorithm.
Note whether the secret is used raw or Base64-decoded.
Store the secret as protected Custom Metadata and expose it through a small
accessor using getAll() so it costs no SOQL query. Never a literal, never a
Custom Setting.
Write the resource in the fixed order: read raw body → read signature
header case-insensitively → reject if absent or malformed → reject if the
timestamp is outside tolerance → Crypto.verifyHMac → only then deserialise.
Make it idempotent. Upsert against the provider's event id on an External
Id, Unique field, and act only when Database.UpsertResult indicates a new row.
Return 2xx immediately and move all processing to a Queueable or a platform
event subscriber. No callouts, no email, no Flow inside the handler.
Expose the endpoint deliberately — Connected App with client credentials, or
Site guest user with the narrowest possible object access — and record which
shape you chose and why.
Test the negatives: tampered body with a captured signature, stale
timestamp with a valid signature, missing header, and a header supplied in
different casing from the handler's spelling.
Review Checklist
Crypto.verifyHMac used, not generateMac plus an equality comparison
The Blob hashed is req.requestBody, untouched — no JSON.serialize between
Signature header read case-insensitively
Signature scheme matched exactly (v1), not by prefix
Timestamp tolerance enforced, and checked before the HMAC
Idempotency via External Id, Unique upsert on the provider's event id
Secret in protected Custom Metadata; comment records raw vs Base64 form
Rejection responses are terse and identical for every failure reason
Raw body, signature header, and secret never logged
Handler returns 2xx before any processing; work is enqueued
Rotation path accepts both current and previous secret
Endpoint exposure (Connected App or Site guest user) documented, with guest
user object access minimised
Negative tests: tampered body, stale timestamp, missing header, odd casing
Salesforce-Specific Gotchas
Full detail in references/gotchas.md.
The Base64 rule on privateKey is bidirectional — encode on both sides or
neither. Key cap is 4 KB.
Apex map keys are case-sensitive; HTTP header names are not. Intermittent
401s in production, green tests locally.
Re-serialising the body guarantees 100% verification failure — which
usually gets "fixed" by deleting the check.
generateMac + equals is a hand-rolled comparison; verifyHMac is not.
A valid signature is not a fresh request. Replay window plus idempotency.
Slow handlers cause retries, not patience. Verify, stage, 200, enqueue.
Apex REST needs a session — webhooks have none. Connected App or Site.
Not every provider uses HMAC. Asymmetric schemes need Crypto.verify.
Logging the payload logs the data, and logging the signature makes a
captured request replayable from your log store.
Accepting any vN scheme is a downgrade attack. Pin v1.
Rotation without dual acceptance is a silent outage of 401s.
Output Artifacts
Artifact
Description
Provider signature contract note
Header name, value grammar, signed-bytes definition, algorithm, secret encoding form, and the provider doc URL it came from
Apex REST resource
Verify → stage → 2xx → enqueue, with a case-insensitive header lookup and terse rejections
Secret metadata
Protected Webhook_Secret__mdt record plus the accessor class, with a rotation note naming when the previous value may be removed
Staging object
External Id, Unique on the provider event id, restricted to the integration's permission set, with a retention policy
Negative test class
Tampered body, stale timestamp, missing header, alternate header casing
Exposure decision record
Connected App vs Site guest user, with the guest user's granted object access enumerated
Related Skills
integration/webhook-inbound-patterns — the receiver design this skill sits
inside: Sites routing, guest-user access model, idempotency schema, and the
response-window budget. Read it first if you are building the endpoint rather
than fixing the signature check.
integration/outbound-webhook-from-salesforce — signing a webhook Salesforce
sends, which is the mirror image of this problem
apex/apex-rest-services — the @RestResource surface itself: URL mapping
rules, supported HTTP methods, and response handling
security/guest-user-security — hardening the Site guest user that a public
webhook endpoint necessarily exposes
integration/retry-and-backoff-patterns — the provider side of at-least-once
delivery, and what your 2xx does and does not promise
1---2name: webhook-signature-verification3description: Accept inbound webhooks (Stripe, GitHub, Slack, partner) and verify HMAC signatures in Apex REST — Crypto.verifyHMac platform verification, secret in Protected Custom Metadata, replay-window rejection. NOT for designing the receiver end to end — Sites routing, guest-user access, idempotency, the 5-second response window — use integration/webhook-inbound-patterns. NOT for signing a webhook Salesforce sends out — use integration/outbound-webhook-from-salesforce.4---567# Webhook Signature Verification89An inbound webhook is an HTTP POST from a third party that Salesforce did not10initiate and cannot authenticate by session. When the endpoint is exposed through11a public Salesforce Site, the signature check is the *entire* authentication12boundary — there is no user, no token, and no network control in front of it.1314Three properties have to hold, and each fails independently:1516| Property | Established by | Fails when |17|---|---|---|18| **Authenticity** — the sender holds the shared secret | HMAC over the payload | Secret is wrong, or the wrong encoding of the right secret |19| **Integrity** — the bytes were not altered in transit | HMAC over the **raw** body | The body was parsed and re-serialised before hashing |20| **Freshness** — this is not a replay of an earlier valid request | Timestamp tolerance + idempotency key | Neither is implemented — the signature alone never establishes this |2122Most broken webhook endpoints have the first two and not the third.2324**Scope.** This skill owns the *verification* step: which Apex crypto method to25call, over which bytes, against which secret, and what to reject. Designing the26receiver end to end — Sites routing, guest-user access model, the idempotency27schema, the response-window budget — belongs to28`integration/webhook-inbound-patterns`. Those subjects appear below only as far as29they change the verification decision (for example: on a public Site the signature30is the only gate, which raises the stakes on every gotcha here). Signing a webhook31Salesforce *sends* is `integration/outbound-webhook-from-salesforce`.3233---3435## Before Starting36371. **Identify the signature scheme.** HMAC (shared secret) or asymmetric38 (RSA/ECDSA against a published public key)? They need different Apex methods39 and different secret storage. Do not assume HMAC.40412. **Get the exact header name, value format, and signed payload.** Not every42 provider signs the body alone — Stripe signs `"{timestamp}.{body}"`. Getting43 this wrong produces 100% verification failure that looks like a key problem.44453. **Decide how the request reaches Apex.** `/services/apexrest/*` requires an46 authenticated session, which a webhook does not have. Either the provider47 obtains a token (Connected App, client credentials flow) or you expose the class48 through a Salesforce Site guest user. Pick before you write the class.49504. **Choose where the secret lives.** Protected Custom Metadata, read via51 `getAll()`. Not a literal, not a Custom Setting, not a Named Credential (which52 is for outbound).5354---5556## Core Concepts5758### `Crypto.verifyHMac` is the method you want5960```apex61public static Boolean Crypto.verifyHMac(62 String algorithmName, // hmacMD5 | hmacSHA1 | hmacSHA256 | hmacSHA51263 Blob data,64 Blob privateKey, // max 4 KB65 Blob macToVerify66)67```6869`Crypto.generateMac` produces a MAC and is correct for *building* a signature (in70tests, or when calling out). It is the wrong tool for checking one, because it71forces you to write the comparison yourself — and `String.equals` short-circuits72on the first differing character. Both Stripe and GitHub explicitly tell73integrators to compare signatures in constant time.7475**What the Apex Reference Guide does and does not say.** Its `verifyHMac` entry76documents the signature, the four valid algorithm names, the 4 KB key cap, the77Base64 symmetry rule, and the Boolean return. It makes **no timing claim**. So78`verifyHMac` is the recommended method here because it keeps the comparison out79of your code entirely, not because Salesforce documents it as constant-time —80that property is undocumented, and this skill does not assert it. Detail in81[`references/gotchas.md`](references/gotchas.md), Gotcha 4.8283For asymmetric providers:8485```apex86public static Boolean Crypto.verify(String algorithmName, Blob data,87 Blob signature, String certDevName)88```8990with the provider's public certificate stored in **Setup → Certificate and Key91Management**, so rotation is a Setup change rather than a deploy.9293### The Base64 symmetry rule9495> "You may supply a private key that has been encoded using Base64 encoding.96> However if you do, then you must also supply the Base64-encoded private key when97> verifying the MAC using the `verifyHMac` method."98> — Apex Reference Guide, `Crypto.generateMac`99100Record in a comment which form the provider uses. Stripe's `whsec_...` is an101opaque ASCII string used raw; some providers issue a Base64 blob that must be102decoded first.103104### Raw body only105106`RestRequest.requestBody` is a `Blob` — the exact bytes the sender hashed.107`JSON.serialize(JSON.deserializeUntyped(...))` reorders keys, strips whitespace,108and normalises numbers, so a MAC computed over the round trip never matches. Verify109first; parse second.110111### Headers are case-insensitive; Apex maps are not112113`RestRequest.headers` is a `Map<String, String>` with exact-match lookup. Proxies114and provider clients normalise header casing differently, so115`headers.get('Stripe-Signature')` can return `null` in production while working in116a test that used the same spelling. Always look up case-insensitively.117118### Size ceiling119120"The maximum request or response size is 6 MB for synchronous Apex or 12 MB for121asynchronous Apex." A provider posting near that ceiling has outgrown the webhook122shape; move to notification-plus-pull.123124---125126## Common Patterns127128### Pattern A — verify, stage, return 200, process async129130The default. The resource verifies, upserts the raw event against an External Id,131enqueues a Queueable, and returns. Providers time out in single-digit seconds and132retry on timeout; anything slower turns one event into repeated deliveries. Full133implementation in [`references/examples.md`](references/examples.md), Example 2.134135### Pattern B — replay window before HMAC136137If the provider signs a timestamp, check it first. A replay flood then costs a138`Long` comparison rather than a crypto operation. Stripe's libraries "have a139default tolerance of 5 minutes between the timestamp and the current time," and140Stripe warns against a tolerance of `0` because it disables the recency check141entirely.142143### Pattern C — accept multiple signatures during rotation144145Stripe emits one signature per active secret while a secret roll is in flight146(previous secret active for up to 24 hours), so a verifier that loops over every147`v1=` value rides the rotation with no downtime. Where the provider does not do148this, hold `Secret__c` and `Previous_Secret__c` on the metadata record and try149both, removing the old value in a separate, dated change.150151### Pattern D — layer IP allowlisting where the provider publishes ranges152153Stripe publishes its webhook source IPs and recommends allowlisting them in154addition to signature verification. This is defence in depth, not a replacement:155IP ranges change, and an allowlist alone proves nothing about payload integrity.156157---158159## Decision Guidance160161| Situation | Approach |162|---|---|163| Provider issues a shared secret | `Crypto.verifyHMac('hmacSHA256', rawBody, secret, mac)` |164| Provider publishes a public certificate / JWKS | `Crypto.verify(alg, data, sig, certDevName)` with the cert in Certificate and Key Management |165| Provider can send a bearer token | Connected App + OAuth 2.0 client credentials flow, **and** keep the signature check |166| Provider cannot authenticate at all | Public Site + guest-user Apex class access; the HMAC check is the only gate |167| Provider signs a derived payload | Reconstruct the derivation from the raw body string, never from a re-serialised object |168| Payload approaches 6 MB | Notification-plus-pull: the event carries an id, Salesforce fetches the body |169| Provider offers several signature schemes | Pin one in code; never let the sender choose the algorithm |170171---172173## Recommended Workflow1741751. **Read the provider's signature documentation and write down four things**:176 header name, value grammar, exactly which bytes are signed, and the algorithm.177 Note whether the secret is used raw or Base64-decoded.1782. **Store the secret as protected Custom Metadata** and expose it through a small179 accessor using `getAll()` so it costs no SOQL query. Never a literal, never a180 Custom Setting.1813. **Write the resource in the fixed order**: read raw body → read signature182 header case-insensitively → reject if absent or malformed → reject if the183 timestamp is outside tolerance → `Crypto.verifyHMac` → *only then* deserialise.1844. **Make it idempotent.** Upsert against the provider's event id on an External185 Id, Unique field, and act only when `Database.UpsertResult` indicates a new row.1865. **Return 2xx immediately** and move all processing to a Queueable or a platform187 event subscriber. No callouts, no email, no Flow inside the handler.1886. **Expose the endpoint deliberately** — Connected App with client credentials, or189 Site guest user with the narrowest possible object access — and record which190 shape you chose and why.1917. **Test the negatives**: tampered body with a captured signature, stale192 timestamp with a valid signature, missing header, and a header supplied in193 different casing from the handler's spelling.194195---196197## Review Checklist198199- [ ] `Crypto.verifyHMac` used, not `generateMac` plus an equality comparison200- [ ] The `Blob` hashed is `req.requestBody`, untouched — no `JSON.serialize` between201- [ ] Signature header read case-insensitively202- [ ] Signature scheme matched exactly (`v1`), not by prefix203- [ ] Timestamp tolerance enforced, and checked before the HMAC204- [ ] Idempotency via External Id, Unique upsert on the provider's event id205- [ ] Secret in protected Custom Metadata; comment records raw vs Base64 form206- [ ] Rejection responses are terse and identical for every failure reason207- [ ] Raw body, signature header, and secret never logged208- [ ] Handler returns 2xx before any processing; work is enqueued209- [ ] Rotation path accepts both current and previous secret210- [ ] Endpoint exposure (Connected App or Site guest user) documented, with guest211 user object access minimised212- [ ] Negative tests: tampered body, stale timestamp, missing header, odd casing213214---215216## Salesforce-Specific Gotchas217218Full detail in [`references/gotchas.md`](references/gotchas.md).2192201. **The Base64 rule on `privateKey` is bidirectional** — encode on both sides or221 neither. Key cap is 4 KB.2222. **Apex map keys are case-sensitive; HTTP header names are not.** Intermittent223 401s in production, green tests locally.2243. **Re-serialising the body guarantees 100% verification failure** — which225 usually gets "fixed" by deleting the check.2264. **`generateMac` + `equals` is a hand-rolled comparison**; `verifyHMac` is not.2275. **A valid signature is not a fresh request.** Replay window plus idempotency.2286. **Slow handlers cause retries, not patience.** Verify, stage, 200, enqueue.2297. **Apex REST needs a session** — webhooks have none. Connected App or Site.2308. **Not every provider uses HMAC.** Asymmetric schemes need `Crypto.verify`.2319. **Logging the payload logs the data**, and logging the signature makes a232 captured request replayable from your log store.23310. **Accepting any `vN` scheme is a downgrade attack.** Pin `v1`.23411. **Rotation without dual acceptance is a silent outage** of 401s.235236---237238## Output Artifacts239240| Artifact | Description |241|---|---|242| Provider signature contract note | Header name, value grammar, signed-bytes definition, algorithm, secret encoding form, and the provider doc URL it came from |243| Apex REST resource | Verify → stage → 2xx → enqueue, with a case-insensitive header lookup and terse rejections |244| Secret metadata | Protected `Webhook_Secret__mdt` record plus the accessor class, with a rotation note naming when the previous value may be removed |245| Staging object | External Id, Unique on the provider event id, restricted to the integration's permission set, with a retention policy |246| Negative test class | Tampered body, stale timestamp, missing header, alternate header casing |247| Exposure decision record | Connected App vs Site guest user, with the guest user's granted object access enumerated |248249---250251## Related Skills252253- `integration/webhook-inbound-patterns` — the receiver design this skill sits254 inside: Sites routing, guest-user access model, idempotency schema, and the255 response-window budget. Read it first if you are building the endpoint rather256 than fixing the signature check.257- `integration/outbound-webhook-from-salesforce` — signing a webhook Salesforce258 sends, which is the mirror image of this problem259- `apex/apex-rest-services` — the `@RestResource` surface itself: URL mapping260 rules, supported HTTP methods, and response handling261- `security/guest-user-security` — hardening the Site guest user that a public262 webhook endpoint necessarily exposes263- `integration/retry-and-backoff-patterns` — the provider side of at-least-once264 delivery, and what your 2xx does and does not promise
Run npx skillmds add pranavnagrecha/webhook-signature-verification 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.
Accept inbound webhooks (Stripe, GitHub, Slack, partner) and verify HMAC signatures in Apex REST — Crypto.verifyHMac platform verification, secret in Protected Custom Metadata, replay-window rejection. NOT for designing the receiver end to end — Sites routing, guest-user access, idempotency, the 5-second response window — use integration/webhook-inbound-patterns. NOT for signing a webhook Salesforce sends out — use integration/outbound-webhook-from-salesforce. It is listed under Security on SkillMD.
This skill has not completed SkillMD's automated safety review yet. Capability flags: executes scripts, 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.