Skip to content
Last updated

HMAC request signing

Sign file upload requests with a shared secret to prove request integrity.


HMAC (hash-based message authentication code) request signing proves that a request came from you and that neither the instructions nor the uploaded file were altered in transit. You compute a signature over the request content using a secret that only you and Wise hold, and Wise recomputes it to verify the request.

Unlike JOSE JWS, which uses an asymmetric key pair and requires you to upload a public key, HMAC uses a single symmetric shared secret. This makes it simpler to set up, but means the secret must never leave your server-side environment.

HMAC signing supplements, but does not replace, your normal authentication. Signed requests must still carry a valid client credentials token in the Authorization header. The signature proves integrity; the token proves identity and grants access.

Where HMAC signing applies

HMAC signing is not enabled across the whole API. Check the API reference for the endpoint you are calling to see whether it requires HMAC signing. The endpoints that currently require it are listed in How it works.

If an endpoint does not require signing, sending the signature headers has no effect. If you are unsure, ask your Wise implementation team.

How it works

Endpoints that require HMAC signing take a multipart/form-data body with two parts:

  • A JSON part: a JSON document describing what to do with the file.
  • A file part: the file to process, such as a CSV of line items.

The name of each part depends on the endpoint:

Endpoint JSON partFile partUsed in
Create settlement allocation requestcommandfileBring Your Own PSP (BYO-PSP)

The examples in this guide use command and file, the part names for Create settlement allocation request. If you are calling a different endpoint, use its part names instead.

Signing covers both parts, so the instructions and the data they refer to are locked together:

  1. Wise shares an HMAC secret with you during onboarding. Each environment has its own secret.
  2. For each request, you build a canonical string from the request timestamp, a hash of the JSON part, and a hash of the file part.
  3. You compute an HMAC-SHA256 of that canonical string using the shared secret, and send it as the X-Signature header alongside the timestamp in X-Signature-Timestamp.
  4. Wise rebuilds the canonical string from the request it actually received, recomputes the HMAC with its copy of the secret, and compares the two in constant time.
  5. If the timestamp is stale or the signatures do not match, Wise rejects the request with a 400 before processing it.

Required headers

Header Required?Description
X-SignatureYesThe HMAC-SHA256 of the canonical string, hex-encoded in lowercase. Always 64 hexadecimal characters.
X-Signature-TimestampYesThe Unix timestamp in seconds (not milliseconds) at which you generated the signature, as a decimal string. For example, 1700000000.

Both headers are mandatory on a signed endpoint. A request missing either one is rejected before the signature is evaluated.

Building the canonical string

The canonical string is three values joined by literal . (period) characters:

{timestamp}.{sha256_hex(json_part)}.{sha256_hex(file_part)}
Component Description
timestampThe exact same value you send in the X-Signature-Timestamp header, as a string. Wise uses the header value to rebuild the canonical string, so any mismatch invalidates the signature.
sha256_hex(json_part)SHA-256 of the raw bytes of the JSON multipart part (for example, command), hex-encoded in lowercase.
sha256_hex(file_part)SHA-256 of the raw bytes of the file multipart part (for example, file), hex-encoded in lowercase.

The order is fixed: timestamp, then the JSON hash, then the file hash. The order does not depend on the part names or the order the parts appear in the request body.

Hash the exact bytes you send

Wise hashes the bytes of each part as they arrive on the wire. If you serialise the JSON twice — once to hash it and once to send it — any difference in key ordering, whitespace, or number formatting produces a different hash and the signature fails.

Serialise the JSON once, hash those bytes, and send those same bytes. The simplest way to guarantee this is to write the JSON to a file or buffer and reference it from both the hash and the request body. The same applies to the file: read it once, in binary mode, and reuse those bytes.

Canonical string example
1700000000.3f44b77e86ebb03e62eba2476ea87079e10dd8fe1ed155f4d086cf7aad236393.7ce6d2a68e9bc3fb7f8b1501e78d8f6567a7d87faa87bfe57e1544a942e84f0d

Computing the signature

Compute HMAC-SHA256(key = secret, message = canonical_string) where:

  • The key is the UTF-8 bytes of the shared secret exactly as Wise provided it. Do not base64-decode, hex-decode, or trim it.
  • The message is the UTF-8 bytes of the canonical string.
  • The output is hex-encoded in lowercase (not base64) and placed in the X-Signature header.

Use your language's standard library rather than implementing HMAC yourself. Every mainstream runtime ships a correct implementation: hmac in Python, crypto.createHmac in Node.js, javax.crypto.Mac in Java, hmac in Go, and openssl dgst -hmac on the command line.

Timestamp tolerance and replay protection

Wise accepts signatures whose timestamp is within 300 seconds (5 minutes) of Wise server time. The tolerance is symmetric, so a timestamp too far in the future is rejected just like a stale one.

This window only bounds how long a captured request can be replayed; it does not make the signature single-use unless the endpoint also tracks replays or uses idempotency.

Because of this:

  • Generate the timestamp immediately before sending the request, not when you build the payload.
  • Keep the hosts that sign requests synchronised with NTP. Clock drift beyond five minutes causes every request to fail.
  • Generate a fresh timestamp and signature on every retry. Re-sending a stored signature after a network timeout fails once the window has passed.

Verify against a known test vector

Most integration failures are canonical string mistakes rather than HMAC mistakes. Isolate the two by testing your signing function against fixed inputs before sending any HTTP request.

Feed your signing function these inputs:

Input Value
Secrettest-secret
Timestamp1700000000
JSON part bytes{"example":"value"} with no trailing newline
File part bytesid,name \n 1,test \n — that is, two lines, each terminated by a single newline

Your code must produce these intermediate and final values:

Output Expected value
JSON part hash3f44b77e86ebb03e62eba2476ea87079e10dd8fe1ed155f4d086cf7aad236393
File part hash7ce6d2a68e9bc3fb7f8b1501e78d8f6567a7d87faa87bfe57e1544a942e84f0d
Canonical string1700000000.3f44b77e86ebb03e62eba2476ea87079e10dd8fe1ed155f4d086cf7aad236393.7ce6d2a68e9bc3fb7f8b1501e78d8f6567a7d87faa87bfe57e1544a942e84f0d
X-Signature1023c8e2a19cc42260968ab2dcf1698e8344a2939ac97f686d21aacb650273e2

Compare each intermediate value, not just the final signature — it tells you exactly where your implementation diverges:

  • A wrong JSON part hash usually means an extra trailing newline or re-serialised JSON.
  • A wrong file part hash usually means CRLF line endings, a missing final newline, or the file being read in text mode.
  • A correct canonical string with a wrong signature means the secret or the output encoding is wrong.
This vector uses a test secret

test-secret is for offline verification of your signing function only. It will not authenticate against sandbox or production. Once your code reproduces the values above, switch to the secret Wise issued you.

Testing with Postman

Postman can sign the request for you with a pre-request script, which is the quickest way to exercise a signed endpoint by hand before writing any code.

Postman's script sandbox cannot read the bytes of a file attached to a form-data field, so the file hash has to be computed outside Postman and supplied as a variable. The script hashes the JSON part itself and reuses the precomputed file hash.

The steps below use the command and file part names. For other endpoints, substitute the part names from the table in How it works, and set JSON_PART_NAME in the script to match.

Step 1. Compute the file hash

Hash your file and copy the result:

shasum -a 256 data.csv | awk '{print $1}'

Step 2. Set up environment variables

Create these variables in your Postman environment:

Variable How to populate it
Hmac-secretThe HMAC shared secret Wise issued you for the environment you are calling. Store it as a Postman secret variable type so it is masked in the UI and excluded from exports.
file-sha256The hash from step 1.
Hmac-timestampLeave empty. The pre-request script sets this on every send.
Hmac-signatureLeave empty. The pre-request script sets this on every send.

Step 3. Configure the request

On your request in Postman:

  • Set the body to form-data, with a command field as text containing your JSON, and a file field as file pointing at your file.
  • Add the two signature headers, referencing the variables the script populates:
X-Signature: {{Hmac-signature}}
X-Signature-Timestamp: {{Hmac-timestamp}}
The JSON field must be a text field

The script reads the JSON via pm.request.body.formdata.get(JSON_PART_NAME), which only returns a value for text fields. If you attach the JSON as a file instead, or JSON_PART_NAME does not match the field name, the script silently hashes an empty string and the signature will not match.

Step 4. Add the pre-request script

Paste this into the request's Scripts → Pre-request tab. It runs on every send, generating a fresh timestamp and signature so your request never falls outside the 300-second window.

Postman pre-request script
// Name of the multipart field holding the JSON part — change this to match your endpoint
const JSON_PART_NAME = "command";

// Helper: SHA-256 of a String (UTF-8) or WordArray, returned as lowercase hex
function sha256Hex(input) {
    return CryptoJS.SHA256(input).toString(CryptoJS.enc.Hex);
}

// 1. Fetch secret key and precomputed file hash from environment
const secret = pm.environment.get("Hmac-secret");
const fileHash = pm.environment.get("file-sha256") || "";

if (!secret) {
    console.error("Missing environment variable: {{Hmac-secret}}");
}
if (!fileHash) {
    console.error("Missing variable: {{file-sha256}}");
}

// 2. Generate current Unix timestamp (in seconds)
const timestamp = Math.floor(Date.now() / 1000).toString();

// 3. Extract the JSON text from the multipart body parameters
let json = "";
if (pm.request.body && pm.request.body.mode === "formdata") {
    const jsonParam = pm.request.body.formdata.get(JSON_PART_NAME);
    if (jsonParam) {
        json = jsonParam;
    }
}
if (!json) {
    console.error(`Missing or empty form-data text field: ${JSON_PART_NAME}`);
}

// 4. SHA-256 (hex) of the JSON part (UTF-8 bytes)
const jsonHash = sha256Hex(json);

// 5. Construct Canonical Data String to Sign:
//    Format: TIMESTAMP . SHA256_HEX(JSON_PART) . SHA256_HEX(FILE_BYTES)
const dataToSign = `${timestamp}.${jsonHash}.${fileHash}`;

// 6. Compute HMAC-SHA256 signature (hex-encoded)
const signature = CryptoJS.HmacSHA256(dataToSign, secret).toString(CryptoJS.enc.Hex);

// 7. Save to ENVIRONMENT variables
pm.environment.set("Hmac-timestamp", timestamp);
pm.environment.set("Hmac-signature", signature);

CryptoJS is available in the Postman sandbox by default; no import is needed.

Step 5. Send the request

Recompute `file-sha256` whenever the file changes

The file hash is not derived from the file at send time. If you edit the file, regenerate the hash from step 1 and update file-sha256, or the signature will be computed over the old content and rejected with Invalid signature.

This is the most common cause of signature failures when testing in Postman. If a request that previously worked starts failing, check this first.

Watch the Postman console for the Missing environment variable warnings from the script. The script logs an error but still sends the request, so an unset secret produces a confusing Invalid signature rather than an obvious configuration error.

Run these against sandbox to confirm both the happy path and your error handling:

Test case Expected result
Valid signatureThe endpoint's normal success response.
Missing X-Signature header400 with detail Missing required X-Signature header.
Missing X-Signature-Timestamp header400 with detail Missing required X-Signature-Timestamp header.
Tampered JSON — sign the original JSON part, then change a field value before sending400 with detail Invalid signature. Confirms the JSON part is covered by the signature.
Tampered file — sign the original file, then modify a row before sending400 with detail Invalid signature. Confirms the file is covered by the signature.
Swapped hashes — build the canonical string with the file hash before the JSON hash400 with detail Invalid signature. Confirms you have the order right.
Expired timestamp — sign with a timestamp 600 seconds in the past400 with detail Signature timestamp expired.
Non-numeric timestamp — send X-Signature-Timestamp: not-a-number400 with detail Invalid timestamp format.
Malformed signature — send a non-hex value in X-Signature400 with detail Invalid signature encoding.
Test with a realistic file

Hash the file from the same bytes you upload, and stream large files rather than holding them in memory twice. Testing with a production-sized file confirms your signing path does not truncate or re-encode the content.

Troubleshooting

All signature failures return 400 Bad Request with a detail field describing the cause. Use the detail value to identify which check failed.

Invalid signature

Wise received a well-formed signature but it did not match the request. The secret, the canonical string, or the request content differs from what you signed.

Cause How to diagnoseSolution
Re-serialised JSONCheck whether your code serialises the JSON separately for the hash and for the request body. Log the byte length of both and compare.Serialise once, then reuse the same byte array for the hash and the request.
Wrong environment secretConfirm the secret you loaded corresponds to the environment you are calling. Compare the base URL (api.wise-sandbox.com vs api.wise.com) against the credential source.Use each environment's own secret.
Secret transformed before useCheck for base64 decoding, hex decoding, trimming, or unquoting applied to the secret before it is used as the HMAC key.Use the raw UTF-8 bytes of the secret exactly as issued. Watch for trailing whitespace or newlines introduced by shell variables or secret managers.
Timestamp mismatchCompare the timestamp inside your canonical string with the value in the X-Signature-Timestamp header. They must be byte-identical.Compute the timestamp once and use the same string in both places. Do not regenerate it after signing.
Hashes in the wrong orderCheck that the JSON hash comes before the file hash in your canonical string.Use the order {timestamp}.{json_part}.{file_part}.
Base64 instead of hexCheck whether X-Signature is 64 lowercase hex characters. Base64 output is 44 characters and contains +, /, or =.Hex-encode the HMAC output.
File altered in transitCheck whether your HTTP client re-encodes the file, for example by normalising line endings from LF to CRLF or applying a text-mode transfer.Read and upload the file in binary mode.
Stale file hash (Postman)If you are testing with the Postman pre-request script, re-run shasum -a 256 on your file and compare it against the file-sha256 variable.Update file-sha256 after every edit to the file. The script does not recompute it.
Empty JSON hash (Postman)Check that the JSON form-data field is a text field and that its name matches JSON_PART_NAME in the script. The script cannot read file-type fields and hashes an empty string if the field is missing.Change the field type from file to text, or correct JSON_PART_NAME.

Signature timestamp expired

The timestamp is more than 300 seconds away from Wise server time, in either direction.

Cause How to diagnoseSolution
Clock driftCompare your signing host's clock against a reliable time source. A host running fast will also fail, because the tolerance is symmetric.Synchronise the host with NTP.
Milliseconds instead of secondsA millisecond timestamp is 13 digits; a second timestamp is 10. Check the length of the value you send.Divide by 1000, or use a seconds-based API such as date +%s or int(time.time()).
Signature generated too earlyMeasure the elapsed time between generating the timestamp and sending the request. Large file uploads and batch jobs that pre-sign many requests can exceed the window.Generate the timestamp and signature immediately before each send.
Stale signature reused on retryCheck whether your retry logic re-sends the original headers.Re-sign on every retry attempt with a fresh timestamp.

Invalid signature encoding

The X-Signature value is not valid hexadecimal. This is usually base64 output, an odd number of characters, or a value carrying a sha256= prefix or surrounding whitespace. Send the bare lowercase hex digest.

Invalid timestamp format

The X-Signature-Timestamp value is not a whole number. Send a decimal integer with no fractional part, no quotes, and no ISO 8601 formatting.

Missing required X-Signature header or Missing required X-Signature-Timestamp header

The header was absent or blank. A common cause is an HTTP client that silently drops headers with empty values when your secret or timestamp variable is unset — check that both values are non-empty before sending.

Contacting support

When contacting Wise about an HMAC issue, include:

  • The x-trace-id from the response headers.
  • The HTTP status code and full response body.
  • The environment (sandbox or production).
  • The endpoint you are calling.
  • The X-Signature-Timestamp you sent and the canonical string your code built.

Never include the shared secret or the signature itself. Contact api@wise.com.

Security best practices

  • Store the HMAC secret in a secrets manager, never in source control, configuration files, or logs.
  • Use separate secrets for sandbox and production.
  • Never log the secret, and avoid logging full signatures.
  • Rotate the secret periodically, and immediately if you suspect exposure. Contact your implementation manager to arrange rotation.
  • Sign server-side only. Because the secret is symmetric, anyone holding it can forge requests, so it must never reach client-side code or an untrusted environment.
  • Compare signatures in constant time if you implement HMAC verification on your own side. Use hmac.compare_digest, crypto.timingSafeEqual, MessageDigest.isEqual, or your platform's equivalent rather than ==.
  • Client credentials tokens — the authentication HMAC signing supplements.
  • JOSE JWS — asymmetric request signing, for endpoints that use a key pair instead of a shared secret.
  • mTLS — mutual TLS for transport-level authentication.
  • Bring Your Own PSP — a flow that uses HMAC-signed file uploads.