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.
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.
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 part | File part | Used in |
|---|---|---|---|
| Create settlement allocation request | command | file | Bring 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:
- Wise shares an HMAC secret with you during onboarding. Each environment has its own secret.
- 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.
- You compute an HMAC-SHA256 of that canonical string using the shared secret, and send it as the
X-Signatureheader alongside the timestamp inX-Signature-Timestamp. - 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.
- If the timestamp is stale or the signatures do not match, Wise rejects the request with a
400before processing it.
| Header | Required? | Description |
|---|---|---|
X-Signature | Yes | The HMAC-SHA256 of the canonical string, hex-encoded in lowercase. Always 64 hexadecimal characters. |
X-Signature-Timestamp | Yes | The 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.
The canonical string is three values joined by literal . (period) characters:
{timestamp}.{sha256_hex(json_part)}.{sha256_hex(file_part)}| Component | Description |
|---|---|
timestamp | The 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.
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.
1700000000.3f44b77e86ebb03e62eba2476ea87079e10dd8fe1ed155f4d086cf7aad236393.7ce6d2a68e9bc3fb7f8b1501e78d8f6567a7d87faa87bfe57e1544a942e84f0dCompute 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-Signatureheader.
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.
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.
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 |
|---|---|
| Secret | test-secret |
| Timestamp | 1700000000 |
| JSON part bytes | {"example":"value"} with no trailing newline |
| File part bytes | id,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 hash | 3f44b77e86ebb03e62eba2476ea87079e10dd8fe1ed155f4d086cf7aad236393 |
| File part hash | 7ce6d2a68e9bc3fb7f8b1501e78d8f6567a7d87faa87bfe57e1544a942e84f0d |
| Canonical string | 1700000000.3f44b77e86ebb03e62eba2476ea87079e10dd8fe1ed155f4d086cf7aad236393.7ce6d2a68e9bc3fb7f8b1501e78d8f6567a7d87faa87bfe57e1544a942e84f0d |
X-Signature | 1023c8e2a19cc42260968ab2dcf1698e8344a2939ac97f686d21aacb650273e2 |
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.
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.
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.
Hash your file and copy the result:
shasum -a 256 data.csv | awk '{print $1}'Create these variables in your Postman environment:
| Variable | How to populate it |
|---|---|
Hmac-secret | The 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-sha256 | The hash from step 1. |
Hmac-timestamp | Leave empty. The pre-request script sets this on every send. |
Hmac-signature | Leave empty. The pre-request script sets this on every send. |
On your request in Postman:
- Set the body to form-data, with a
commandfield as text containing your JSON, and afilefield 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 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.
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.
// 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.
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 signature | The endpoint's normal success response. |
Missing X-Signature header | 400 with detail Missing required X-Signature header. |
Missing X-Signature-Timestamp header | 400 with detail Missing required X-Signature-Timestamp header. |
| Tampered JSON — sign the original JSON part, then change a field value before sending | 400 with detail Invalid signature. Confirms the JSON part is covered by the signature. |
| Tampered file — sign the original file, then modify a row before sending | 400 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 hash | 400 with detail Invalid signature. Confirms you have the order right. |
| Expired timestamp — sign with a timestamp 600 seconds in the past | 400 with detail Signature timestamp expired. |
Non-numeric timestamp — send X-Signature-Timestamp: not-a-number | 400 with detail Invalid timestamp format. |
Malformed signature — send a non-hex value in X-Signature | 400 with detail Invalid signature encoding. |
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.
All signature failures return 400 Bad Request with a detail field describing the cause. Use the detail value to identify which check failed.
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 diagnose | Solution |
|---|---|---|
| Re-serialised JSON | Check 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 secret | Confirm 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 use | Check 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 mismatch | Compare 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 order | Check that the JSON hash comes before the file hash in your canonical string. | Use the order {timestamp}.{json_part}.{file_part}. |
| Base64 instead of hex | Check whether X-Signature is 64 lowercase hex characters. Base64 output is 44 characters and contains +, /, or =. | Hex-encode the HMAC output. |
| File altered in transit | Check 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. |
The timestamp is more than 300 seconds away from Wise server time, in either direction.
| Cause | How to diagnose | Solution |
|---|---|---|
| Clock drift | Compare 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 seconds | A 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 early | Measure 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 retry | Check whether your retry logic re-sends the original headers. | Re-sign on every retry attempt with a fresh timestamp. |
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.
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.
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.
When contacting Wise about an HMAC issue, include:
- The
x-trace-idfrom the response headers. - The HTTP status code and full response body.
- The environment (sandbox or production).
- The endpoint you are calling.
- The
X-Signature-Timestampyou sent and the canonical string your code built.
Never include the shared secret or the signature itself. Contact api@wise.com.
- 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.