{{ ... }} expression, so an HTTP step can sign its own request without a Code step.
Functions
algorithm is one of md5, sha1, sha224, sha256, sha384, sha512 — dashes and case are ignored, so SHA-256 works. sha1 and md5 are there because some vendors still require them.
encoding is hex, base64 or base64url.
$rsasign takes a PEM-encoded, unencrypted RSA private key and accepts only sha256, sha384 and sha512 — RS256/384/512, using PKCS#1 v1.5 padding. A key copied out of a service-account JSON file keeps its \n escapes; that is read correctly, so paste it as-is.
key_encoding says how to read the key before signing: utf8 (default), hex or base64. Reach for it when a vendor issues a hex- or base64-encoded signing key — signing its printable form instead of the bytes it stands for produces a signature that never verifies — or when chaining HMACs, as AWS does.
For the timestamp these schemes want, use {{ $floor($millis() / 1000) }} (Unix seconds).
Sign the bytes you actually send
The signature has to cover the request body byte for byte. With Body type: JSON, the step serializes your object on the way out, so a signature computed over a string you built separately will not match what is sent. Build the body once as a string, sign that same string, and send it with Body type: Raw:1
Compute the parts once, in a Transform step
Timestamp and nonce must be identical in the signed string and in the headers.
$millis() and $uuid() return something new on every call, so evaluating them twice gives you two different values and a signature that cannot verify.2
Sign them in the header
3
Send the same string as a raw body
Body type: Raw, body:
{{ sign.body }}, with Content-Type: application/json set by hand.Recipes
Each of these follows a vendor’s published scheme. Check the vendor’s docs for the exact canonical string — they differ in ways that matter. Stripe / Svix style —timestamp.body, hex:
v0: prefix, hex, with the timestamp in its own header:
Use
$credential(...), not $secret(...). $secret() resolves only inside a credential’s own refresher workflow and fails the step anywhere else.$secret(): store client_email and private_key from the JSON key file as the credential’s setup secrets, POST the assertion below to https://oauth2.googleapis.com/token with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, and return { access_token, expires_in } from the Output step. Every other workflow then reads {{ $credential('gcs').access_token }} and never touches the key.
key_encoding is for:
x-amz-content-sha256: {{ $hash(sign.body) }}.
That is the signing key only. The full scheme then signs a canonical request — method, path, sorted query string, the signed headers in lowercase order, and the payload hash — which you have to assemble in the Transform step alongside the body, since the step does not expose the URL it finally sends. If you need all of SigV4 rather than one signed header, a Code step with botocore is less work.
Verifying a signature
$hmacVerify is the other direction: it recomputes the HMAC of message and compares it with a signature someone sent you, in constant time. The arguments are $hmac’s, in the same order, with the signature third; encoding says how that signature is written (hex, base64 or base64url; hex is case-insensitive and base64 may be padded or not).
It answers true or false, and the two kinds of mistake behave differently:
- Anything wrong with the signature is
false. Missing, empty, the wrong length, not valid hex or base64: it came from outside the workflow, so a bad one is an answer, not an error. - Anything wrong with your side fails the step, exactly as with
$hmac: a missing key or message, an unknown algorithm, an unknown encoding. A workflow whose credential is missing should error, not quietly reject everyone.
user_id:email:ts and passed along with the signature, so the fields reach the workflow as input. Rebuild the signed string from the fields you received, byte for byte, and check the timestamp in the same Condition so a copied payload stops working after a while:
anyreach.identify() payload this way, with the fields under n1.user_context (the id passed to identify() arrives as user_context.external_user_id).
Write it so that true means verified, as above, and send only the true branch to success. A missing or non-numeric ts makes the timestamp comparison evaluate to nothing, which a Condition treats as false; a condition that tests for a stale payload and sends its false branch to success would let those through.
To rotate the signing secret, add the new one as a second credential and accept either during the overlap:
Notes
- Signing keys belong in credentials, not pasted into a step. A credential’s value is redacted out of the stored run record; a key typed into a header is not.
- The signature itself is recorded in the run’s request log. That is intended — a signature is not secret, and seeing it is how you debug a rejected request. Redaction is exact-match and cannot follow a value through a hash, so do not build a signature over a secret you would rather not see derived.
$hmacVerifychecks signatures over strings you can rebuild, like the payload above, whose fields arrive in the workflow’s input. It cannot check a webhook’s body signature: a trigger receives the parsed payload, not the raw bytes the vendor signed.

