Skip to main content
Some APIs authenticate each request with a signature instead of (or as well as) a token: you HMAC a canonical string the vendor specifies — normally a timestamp joined to the request body — with a shared secret, and send the digest in a header. The signature changes on every request, and the timestamp bounds how long a captured request stays replayable. These functions do that inside an ordinary {{ ... }} 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).
A failed signature never evaluates to an empty string. If the key is missing, the message is not a string, or the algorithm is unknown, the step fails — an ordinary JSONata error would quietly produce "", and a request signed with an empty key looks perfectly well-formed to everything except the vendor.

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:
Slack style — a v0: prefix, hex, with the timestamp in its own header:
Shopify style — body only, base64, no timestamp:
Twilio style — HMAC-SHA1 over the request URL with its POST parameters appended in key order, base64. The step’s final URL (after query params are merged and percent-encoded) is not available to expressions, so build the canonical string yourself in the Transform and send the same values as the body:
An HS256 JWT — assembled from base64url parts:
Use $credential(...), not $secret(...). $secret() resolves only inside a credential’s own refresher workflow and fails the step anywhere else.
A Google service-account assertion (RS256) — the JWT bearer flow behind every Google API. This belongs in a refresher workflow, which is why it reads $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.
Google caps the assertion’s lifetime at one hour, and the access token it returns lasts the same — set the credential’s refresh policy to before expiry and it re-mints itself. AWS SigV4 signing key — four chained HMACs, each keyed on the previous digest. This is what key_encoding is for:
SigV4 also wants a payload hash 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.
The key comes first, as in $hmac. Swapping the key and the message does not fail: it returns false for every signature, which looks like a bad signature rather than a mistake.
The typical use is checking a payload your own server signed before the workflow acts on it: a user id, an email and a timestamp, say, signed as 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:
A verifier workflow checks a page’s signed 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.
  • $hmacVerify checks 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.