Sign RFI multipart file uploads with native form-data or complete-body HMAC signing.
Signing RFI multipart file uploads
This page applies to production requests that use HMAC authentication. In sandbox, use bearer authentication and send the multipart form normally. Do not send X-OM-Content-SHA256: UNSIGNED-PAYLOAD with a bearer token.
In production, how you sign an RFI file upload depends on whether your HTTP client lets you access the final encoded multipart body. If it does, sign the complete body. If it builds and sends the body internally, use native form-data signing.
Both modes use the same access key, timestamp, HTTP method, request path, signature algorithm, and Authorization header format described in Authentication.
Choose a signing mode
| Mode | When to use it | Request header | Canonical payload |
|---|---|---|---|
| Native form-data | Postman, curl --form, or server-side clients that do not expose the final serialized multipart bytes | X-OM-Content-SHA256: UNSIGNED-PAYLOAD | Signed Idempotency-Key plus UNSIGNED-PAYLOAD |
| Complete body | Clients that can prepare the multipart body once and send the same bytes | Omit X-OM-Content-SHA256 | SHA-256 of the complete serialized multipart body |
Do not sign production requests in browser code. Send browser uploads through your server so the secret key remains server-side.
UNSIGNED-PAYLOAD is an explicit route-level exception. It is supported only for:
POST /v1/customers/{customer_id}/rfi-casesPOST /v1/customers/{customer_id}/rfi-cases/{case_id}/submissions
The request must use HMAC authentication and multipart/form-data, and it must include a UUID Idempotency-Key. The server rejects this marker on other methods, content types, or routes.
The two endpoints use different multipart fields. A response to an existing case sends type, document_type, note, and file. A proactive submission also sends subject_type=TRANSACTION and subject_id. Follow the request schema for the endpoint you are calling; the signing rules are the same.
For HMAC-authenticated JSON requests and other endpoints, use the standard complete-body signature.
Native form-data with UNSIGNED-PAYLOAD
UNSIGNED-PAYLOADSet these request headers:
X-OM-Content-SHA256: UNSIGNED-PAYLOAD
Idempotency-Key: <IDEMPOTENCY_KEY>Build this exact canonical string:
<ACCESS_KEY>
<TIMESTAMP>
POST
<PATH>
<IDEMPOTENCY_KEY>
UNSIGNED-PAYLOADUse one newline between fields and no trailing newline. PATH contains only the request path, without the domain or query string. The Idempotency-Key in the canonical string must exactly match the request header.
Calculate the HMAC-SHA256 signature from this string, then send the multipart form normally. The HTTP client may generate the boundary and serialize the form after the pre-request script runs.
Postman pre-request script
The following script supports both standard raw-body requests and the two supported RFI form-data routes. Configure ACCESS_KEY and SECRET_KEY as Postman variables. For RFI form-data requests, also set an Idempotency-Key header and select Body → form-data.
/**
* 1Money HMAC-SHA256 signing for raw bodies and native multipart/form-data.
*/
const ACCESS_KEY = pm.environment.get("ACCESS_KEY");
const SECRET_KEY = pm.environment.get("SECRET_KEY");
const METHOD = pm.request.method.toUpperCase();
const PATH = pm.variables.replaceIn(pm.request.url.getPath());
if (!ACCESS_KEY || !SECRET_KEY) {
throw new Error("ACCESS_KEY and SECRET_KEY are required");
}
if (pm.request.body && pm.request.body.mode === "raw") {
const resolvedBody = pm.variables.replaceIn(pm.request.body.raw || "");
pm.request.body.update({
mode: "raw",
raw: resolvedBody,
});
}
function timestampUtc() {
return new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d+Z$/, "Z");
}
function decodeBase64Url(value) {
let base64 = value.replace(/-/g, "+").replace(/_/g, "/");
while (base64.length % 4 !== 0) {
base64 += "=";
}
return CryptoJS.enc.Base64.parse(base64);
}
const timestamp = timestampUtc();
const isMultipart =
pm.request.body && pm.request.body.mode === "formdata";
const isSupportedRfiMultipart =
isMultipart &&
METHOD === "POST" &&
[
/^\/v1\/customers\/[^/]+\/rfi-cases$/,
/^\/v1\/customers\/[^/]+\/rfi-cases\/[^/]+\/submissions$/,
].some((pattern) => pattern.test(PATH));
let stringToSign;
if (isMultipart && !isSupportedRfiMultipart) {
throw new Error(
"UNSIGNED-PAYLOAD is supported only for the two RFI multipart routes",
);
}
if (isSupportedRfiMultipart) {
const idempotencyHeader = pm.request.headers.get("Idempotency-Key");
const idempotencyKey = pm.variables.replaceIn(idempotencyHeader || "");
if (!idempotencyKey) {
throw new Error(
"Idempotency-Key is required for multipart UNSIGNED-PAYLOAD",
);
}
pm.request.headers.upsert({
key: "X-OM-Content-SHA256",
value: "UNSIGNED-PAYLOAD",
});
stringToSign = [
ACCESS_KEY,
timestamp,
METHOD,
PATH,
idempotencyKey,
"UNSIGNED-PAYLOAD",
].join("\n");
} else {
pm.request.headers.remove("X-OM-Content-SHA256");
const body = pm.request.body ? pm.request.body.raw || "" : "";
const bodyHash = CryptoJS.SHA256(body).toString(CryptoJS.enc.Hex);
stringToSign = [
ACCESS_KEY,
timestamp,
METHOD,
PATH,
bodyHash,
].join("\n");
}
const signature = CryptoJS.HmacSHA256(
stringToSign,
decodeBase64Url(SECRET_KEY),
).toString(CryptoJS.enc.Hex);
pm.request.headers.upsert({
key: "Authorization",
value:
`OneMoney-HMAC-SHA256 ${ACCESS_KEY}:${timestamp}:${signature}`,
});
pm.request.headers.upsert({
key: "X-OM-Date",
value: timestamp,
});Do not manually set the multipart Content-Type in Postman. Postman adds the matching boundary when it serializes the form.
Security properties
This mode authenticates the API key, timestamp, method, path, and Idempotency-Key. It does not cryptographically bind the multipart body to the HMAC signature.
TLS is therefore required. The server still validates the multipart structure, allowed fields, field values, filename, file extension, file content, and size. Idempotent retries also compare the logical payload and reject reuse of the same key with different content.
This design keeps native multipart integrations possible without requiring clients to reproduce an HTTP library's private serialization format. Complete-body signing remains available when end-to-end payload authentication is required and the client can access the prepared bytes.
Complete-body signing
What is signed
Calculate:
BODY_HASH = sha256_hex(EXACT_SERIALIZED_MULTIPART_BODY)The serialized body includes:
- Every boundary delimiter.
- Every part header, including
Content-Disposition, field names, filenames, and per-partContent-Typevalues. - The line breaks and ordering produced by the multipart encoder.
- Every text-field value and every file byte.
- The final closing boundary.
Do not use the SHA-256 hash of an empty body. Do not hash only the file or only the text fields.
Who chooses the boundary
The client chooses the boundary. Most HTTP libraries generate one when they serialize a multipart form; a client may also supply any valid multipart boundary.
1Money does not provide or require a fixed boundary. The boundary in the Content-Type header must match the delimiters in the body:
Content-Type: multipart/form-data; boundary=boundary-abc123The important requirement is byte identity: the body sent over the network must be exactly the body whose hash was included in the HMAC signature.
Required request sequence
- Create the multipart fields and file parts.
- Serialize the multipart form once. This produces both the final body bytes and a
Content-Typeheader containing the boundary. - Calculate
BODY_HASHfrom those final body bytes. - Build the standard HMAC canonical string and signature.
- Add
AuthorizationandX-OM-Dateto the already prepared request. - Send that prepared body without rebuilding or reserializing it.
Do not prepare one multipart request for signing and then ask the HTTP library to create a second multipart request for sending. A second serialization may use a different boundary, part ordering, filename encoding, or line breaks, causing signature verification to fail.
If a retry rebuilds the multipart body, generate a new signature for the rebuilt bytes. Reusing the same prepared body is also valid while the timestamp remains within the accepted signing window.
Complete-body examples
Each example serializes the multipart body once, calculates the signature from those bytes, and sends the same bytes. Choose your language:
import base64
import hashlib
import hmac
from datetime import datetime, timezone
import requests
ACCESS_KEY = "your_access_key"
SECRET_KEY = "your_base64_url_safe_secret"
BASE_URL = "https://api.1money.com"
CUSTOMER_ID = "your_customer_id"
CASE_ID = "your_case_id"
PATH = f"/v1/customers/{CUSTOMER_ID}/rfi-cases/{CASE_ID}/submissions"
METHOD = "POST"
def decode_secret_key(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(value + padding)
session = requests.Session()
with open("contract-agreement.pdf", "rb") as file:
request = requests.Request(
method=METHOD,
url=BASE_URL + PATH,
headers={
"Idempotency-Key": "8fc27a28-8d26-4df7-8f5d-012fa953e52b",
},
data=[
("type", "FILE"),
("document_type", "CONTRACT_AGREEMENT"),
("note", "Signed agreement for invoice INV-2026-001"),
],
files={
"file": (
"contract-agreement.pdf",
file,
"application/pdf",
),
},
)
prepared = session.prepare_request(request)
body = prepared.body
if not isinstance(body, bytes):
raise TypeError("Expected the prepared multipart body to be bytes")
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
body_hash = hashlib.sha256(body).hexdigest()
string_to_sign = "\n".join(
[ACCESS_KEY, timestamp, METHOD, PATH, body_hash]
)
signature = hmac.new(
decode_secret_key(SECRET_KEY),
string_to_sign.encode("utf-8"),
hashlib.sha256,
).hexdigest()
prepared.headers["Authorization"] = (
f"OneMoney-HMAC-SHA256 {ACCESS_KEY}:{timestamp}:{signature}"
)
prepared.headers["X-OM-Date"] = timestamp
response = session.send(prepared)
response.raise_for_status()// npm install form-data
const crypto = require("node:crypto");
const fs = require("node:fs");
const https = require("node:https");
const FormData = require("form-data");
const accessKey = "your_access_key";
const secretKey = "your_base64_url_safe_secret";
const customerId = "your_customer_id";
const caseId = "your_case_id";
const path =
`/v1/customers/${customerId}/rfi-cases/${caseId}/submissions`;
const method = "POST";
const form = new FormData();
form.append("type", "FILE");
form.append("document_type", "CONTRACT_AGREEMENT");
form.append("note", "Signed agreement for invoice INV-2026-001");
form.append("file", fs.readFileSync("contract-agreement.pdf"), {
filename: "contract-agreement.pdf",
contentType: "application/pdf",
});
// getBuffer() performs the one serialization used for both signing and sending.
const body = form.getBuffer();
const timestamp = new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}Z$/, "Z");
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
const stringToSign = [
accessKey,
timestamp,
method,
path,
bodyHash,
].join("\n");
const signature = crypto
.createHmac("sha256", Buffer.from(secretKey, "base64url"))
.update(stringToSign, "utf8")
.digest("hex");
const request = https.request(
{
hostname: "api.1money.com",
path,
method,
headers: {
...form.getHeaders(),
"Content-Length": body.length,
"Idempotency-Key": "8fc27a28-8d26-4df7-8f5d-012fa953e52b",
"Authorization":
`OneMoney-HMAC-SHA256 ${accessKey}:${timestamp}:${signature}`,
"X-OM-Date": timestamp,
},
},
(response) => {
response.pipe(process.stdout);
if ((response.statusCode ?? 500) >= 400) {
process.exitCode = 1;
}
},
);
request.on("error", (error) => {
console.error(error);
process.exitCode = 1;
});
request.end(body);import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.HexFormat;
import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class MultipartHmacExample {
private static final String CRLF = "\r\n";
public static void main(String[] args) throws Exception {
String accessKey = "your_access_key";
String secretKey = "your_base64_url_safe_secret";
String customerId = "your_customer_id";
String caseId = "your_case_id";
String requestPath = "/v1/customers/" + customerId
+ "/rfi-cases/" + caseId + "/submissions";
String method = "POST";
String boundary = "1money-" + UUID.randomUUID();
ByteArrayOutputStream multipart = new ByteArrayOutputStream();
addText(multipart, boundary, "type", "FILE");
addText(
multipart,
boundary,
"document_type",
"CONTRACT_AGREEMENT"
);
addText(
multipart,
boundary,
"note",
"Signed agreement for invoice INV-2026-001"
);
addFile(
multipart,
boundary,
"file",
"contract-agreement.pdf",
"application/pdf",
Files.readAllBytes(Path.of("contract-agreement.pdf"))
);
write(multipart, "--" + boundary + "--" + CRLF);
byte[] body = multipart.toByteArray();
String timestamp = DateTimeFormatter
.ofPattern("yyyyMMdd'T'HHmmss'Z'")
.withZone(ZoneOffset.UTC)
.format(Instant.now());
String bodyHash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(body)
);
String stringToSign = String.join(
"\n",
accessKey,
timestamp,
method,
requestPath,
bodyHash
);
Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(
decodeSecretKey(secretKey),
"HmacSHA256"
));
String signature = HexFormat.of().formatHex(
hmac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8))
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.1money.com" + requestPath))
.header(
"Content-Type",
"multipart/form-data; boundary=" + boundary
)
.header(
"Idempotency-Key",
"8fc27a28-8d26-4df7-8f5d-012fa953e52b"
)
.header(
"Authorization",
"OneMoney-HMAC-SHA256 " + accessKey + ":"
+ timestamp + ":" + signature
)
.header("X-OM-Date", timestamp)
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient().send(
request,
HttpResponse.BodyHandlers.ofString()
);
System.out.println(response.statusCode());
System.out.println(response.body());
}
private static void addText(
ByteArrayOutputStream body,
String boundary,
String name,
String value
) {
write(body, "--" + boundary + CRLF);
write(
body,
"Content-Disposition: form-data; name=\"" + name + "\""
+ CRLF + CRLF
);
write(body, value + CRLF);
}
private static void addFile(
ByteArrayOutputStream body,
String boundary,
String name,
String filename,
String contentType,
byte[] file
) throws Exception {
write(body, "--" + boundary + CRLF);
write(
body,
"Content-Disposition: form-data; name=\"" + name
+ "\"; filename=\"" + filename + "\"" + CRLF
);
write(body, "Content-Type: " + contentType + CRLF + CRLF);
body.write(file);
write(body, CRLF);
}
private static void write(
ByteArrayOutputStream body,
String value
) {
body.writeBytes(value.getBytes(StandardCharsets.UTF_8));
}
private static byte[] decodeSecretKey(String value) {
int padding = (4 - value.length() % 4) % 4;
return Base64.getUrlDecoder().decode(value + "=".repeat(padding));
}
}package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"strings"
"time"
)
func main() {
accessKey := "your_access_key"
secretKey := "your_base64_url_safe_secret"
customerID := "your_customer_id"
caseID := "your_case_id"
requestPath := fmt.Sprintf(
"/v1/customers/%s/rfi-cases/%s/submissions",
customerID,
caseID,
)
method := http.MethodPost
var multipartBody bytes.Buffer
writer := multipart.NewWriter(&multipartBody)
writeField(writer, "type", "FILE")
writeField(writer, "document_type", "CONTRACT_AGREEMENT")
writeField(
writer,
"note",
"Signed agreement for invoice INV-2026-001",
)
file, err := os.Open("contract-agreement.pdf")
if err != nil {
log.Fatal(err)
}
defer file.Close()
part, err := writer.CreateFormFile("file", "contract-agreement.pdf")
if err != nil {
log.Fatal(err)
}
if _, err := io.Copy(part, file); err != nil {
log.Fatal(err)
}
if err := writer.Close(); err != nil {
log.Fatal(err)
}
// Bytes() is now the final body used for both signing and sending.
body := multipartBody.Bytes()
bodyDigest := sha256.Sum256(body)
timestamp := time.Now().UTC().Format("20060102T150405Z")
stringToSign := strings.Join(
[]string{
accessKey,
timestamp,
method,
requestPath,
hex.EncodeToString(bodyDigest[:]),
},
"\n",
)
secret, err := base64.RawURLEncoding.DecodeString(secretKey)
if err != nil {
log.Fatal(err)
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(stringToSign))
signature := hex.EncodeToString(mac.Sum(nil))
request, err := http.NewRequest(
method,
"https://api.1money.com"+requestPath,
bytes.NewReader(body),
)
if err != nil {
log.Fatal(err)
}
request.Header.Set("Content-Type", writer.FormDataContentType())
request.Header.Set(
"Idempotency-Key",
"8fc27a28-8d26-4df7-8f5d-012fa953e52b",
)
request.Header.Set(
"Authorization",
fmt.Sprintf(
"OneMoney-HMAC-SHA256 %s:%s:%s",
accessKey,
timestamp,
signature,
),
)
request.Header.Set("X-OM-Date", timestamp)
response, err := http.DefaultClient.Do(request)
if err != nil {
log.Fatal(err)
}
defer response.Body.Close()
fmt.Println(response.Status)
}
func writeField(writer *multipart.Writer, name, value string) {
if err := writer.WriteField(name, value); err != nil {
log.Fatal(err)
}
}In Python, requests sets prepared.body and the matching multipart Content-Type during prepare_request. The Node.js, Java, and Go examples similarly keep the serialized body in memory, sign it, and send it without another encoding pass.
High-level HTTP libraries
Use UNSIGNED-PAYLOAD on the supported RFI routes when a high-level server-side multipart method does not expose the final serialized bytes. Postman and plain curl --form can send these requests without manually controlling the boundary.
For complete-body signing, the library must let the caller access the final serialized bytes before sending, or install a signing hook after body serialization. Do not use plain curl --form for complete-body signing because it chooses and serializes the boundary internally while sending.
Troubleshooting signature mismatches
If a multipart request returns a signature mismatch, compare the body that was hashed with the body that was sent. Common causes are:
UNSIGNED-PAYLOADwas used on an unsupported route, method, or content type.- The
Idempotency-Keyin the canonical string differs from the request header. - The
X-OM-Content-SHA256value is missing, duplicated, or not exactlyUNSIGNED-PAYLOAD. - The multipart form was serialized twice and received a new boundary.
- The
Content-Typeboundary does not match the body delimiters. - Part order, part headers, filename encoding, or line endings changed after signing.
- The file stream was consumed, replaced, or transformed between signing and sending.
- The method or request path used in the canonical string differs from the request.
- The timestamp in
Authorizationis malformed or expired.
For complete-body signing, log body length and a SHA-256 hash when diagnosing integration issues. Do not log raw document bytes, secrets, or the complete Authorization header.
