Authentication
DEEPayment Merchant API authenticates every request with an Ed25519 signature (RFC 9421 HTTP Message Signatures). Every POST body is additionally encrypted to the platform's X25519 public key with a libsodium sealed box. There are no bearer tokens, shared secrets, or HMAC.
The merchant generates its own Ed25519 key pair and shares only the public key. DEEPayment never receives or stores the merchant private key. The SDK provided by the platform handles signing, digesting, and body sealing; this page describes the wire protocol it implements.
Credentials
After onboarding, configure these values in the merchant portal and in your integration. For the commands that generate the merchant key pair and convert it for the SDK, see Key setup.
Access Key
Public merchant identifier. Send it in Merchant-Access-Key.
Merchant Ed25519 private key
Generated by you. Signs every request. Upload only the matching public key to the merchant portal. See Key setup.
Platform X25519 body key
Public key plus keyId, shown in the merchant portal. Encrypts every POST body.
Platform webhook Ed25519 public key
Shown in the merchant portal. Verifies webhook signatures. See Payment webhook.
Keys are environment specific. Do not reuse production keys in test environments.
Summary
- Signature
- Ed25519 over an RFC 9421 signature base; label
merchant;alg="ed25519". - Time window
createdandexpiresare Unix seconds.expires - createdmust be at most 300 seconds. The server rejects expired signatures.- Replay protection
nonceis a UUID v4 regenerated for every HTTP request. A reused nonce is rejected.- Body encryption
POSTbodies are a sealed box envelope,Content-Encryption: sealedbox-v1-x25519-xsalsa20poly1305.GETrequests carry no body.- Body integrity
Content-Digest: sha-256=:base64:(RFC 9530) over the wire body bytes, that is the encrypted envelope.
Request Types
Protected endpoints use exactly two request shapes.
| Type | HTTP | Body | Query | Purpose |
|---|---|---|---|---|
| Write | POST | Sealed box envelope, Content-Type: application/json | Not allowed | Create, cancel, confirm, update |
| Read | GET | Not allowed | Public identifiers only | Query orders, balances, rates, receipts |
PUT, PATCH, and DELETE are not used. Identifiers such as orderNo, merchantOrderNo, currency, method, paging, and time ranges may appear in a query string. Documents, account numbers, phone numbers, emails, card data, and extra must never be sent in a query string.
Request Headers
| Header | Write (POST) | Read (GET) | Value |
|---|---|---|---|
Merchant-Access-Key | Required, signed | Required, signed | Your Access Key |
Content-Type | Required, signed | Not sent | application/json |
Content-Encryption | Required, signed | Not sent | sealedbox-v1-x25519-xsalsa20poly1305 |
Content-Digest | Required, signed | Not sent | sha-256=:<base64(SHA-256(wire body))>: |
Idempotency-Key | Required, signed | Not sent | UUID v4, fresh per request; carried for tracing, never used for deduplication |
Signature-Input | Required | Required | Covered components and parameters, label merchant |
Signature | Required | Required | merchant=:<base64(Ed25519 signature)>: |
Content-Encoding must be absent or identity. Do not send compressed bodies.
Idempotency-Key does not deduplicate. The platform deduplicates on merchantOrderNo alone; the key is carried for tracing and may take a fresh value on every attempt. nonce is a per-request replay key and must be new on every HTTP request, including retries.
Signature-Input
Signature-Input is a single header line. It names the covered components and carries four fixed parameters.
Write request:
Signature-Input: merchant=("@method" "@path" "content-type" "content-encryption" "content-digest" "idempotency-key" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"
Read request:
Signature-Input: merchant=("@method" "@path" "@query" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"
createdUnix seconds at signing time.
expiresUnix seconds. At most created + 300.
nonceUUID v4, new for every HTTP request.
algFixed ed25519.
The server accepts exactly these two component lists. Adding, removing, or reordering components fails authentication. The keyid parameter is not used; the merchant public key is selected by Merchant-Access-Key.
Signature Base
Build one line per covered component, in the order listed in Signature-Input, then a final "@signature-params" line whose value is the Signature-Input value without the merchant= label. Join lines with \n and do not append a trailing newline.
Write request:
"@method": POST
"@path": /api/v1/payments
"content-type": application/json
"content-encryption": sealedbox-v1-x25519-xsalsa20poly1305
"content-digest": sha-256=:UlEd3zTsYBmWqEb8EjQ/zFweyyH2SzENXUPpiLdKoew=:
"idempotency-key": 018fb9b4-95f3-4a47-8f08-27466f7d4c1d
"merchant-access-key": mak_live_test
"@signature-params": ("@method" "@path" "content-type" "content-encryption" "content-digest" "idempotency-key" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"
Read request:
"@method": GET
"@path": /api/v1/payments
"@query": ?orderNo=P202608270001
"merchant-access-key": mak_live_test
"@signature-params": ("@method" "@path" "@query" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"
@methodis the uppercase HTTP method.@pathis the URL path, for example/api/v1/payments.@queryis?followed by the raw query string exactly as sent. Do not reorder, re-encode, or drop empty values. When the URL has no query string, the value is?.- Header component values are the exact header values sent on the wire.
Then:
signature = Ed25519.Sign(merchantPrivateKey, signatureBase)
Signature: merchant=:base64(signature):
Body Encryption
For POST requests, serialize the business JSON, seal it with the platform X25519 public key, and send this envelope as the HTTP body:
{
"version": 1,
"alg": "sealedbox-v1-x25519-xsalsa20poly1305",
"keyId": "body_20260827_01",
"ciphertext": "base64(sealed box output)"
}
versionFixed 1.
algFixed sealedbox-v1-x25519-xsalsa20poly1305. Must equal the Content-Encryption header.
keyIdThe platform body key id shown in the merchant portal. Selects the platform private key used for decryption.
ciphertextStandard base64 of the sealed box output (libsodium crypto_box_seal).
- The sealed box output already contains the ephemeral public key and the authentication tag. No separate
nonce,iv, ortagis transmitted. - The envelope must contain exactly these four fields. Unknown fields or trailing content are rejected.
Content-Digestis computed over the envelope bytes as sent, not over the plaintext.- Plaintext business JSON is limited to 1 MiB; the wire body is limited to 2 MiB.
- The ciphertext is non-deterministic. Retrying the same create produces a different envelope and digest; what makes it the same create is the unchanged
merchantOrderNo, not the header.
Business fields such as merchantOrderNo, amount, and paymentMethod go inside the plaintext exactly as documented in the API Reference.
Examples
Write request:
POST /api/v1/payments HTTP/1.1
Content-Type: application/json
Content-Encryption: sealedbox-v1-x25519-xsalsa20poly1305
Content-Digest: sha-256=:UlEd3zTsYBmWqEb8EjQ/zFweyyH2SzENXUPpiLdKoew=:
Idempotency-Key: 018fb9b4-95f3-4a47-8f08-27466f7d4c1d
Merchant-Access-Key: mak_live_test
Signature-Input: merchant=("@method" "@path" "content-type" "content-encryption" "content-digest" "idempotency-key" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"
Signature: merchant=:FUfY/vgI3YaMjCD1IGYHDIe2yXHkEiyM8ALs5/nYlfR5LKwWL/vJRQybGM9OOV89g3vUjWyQo0tM5AKUmeA9CQ==:
{"version":1,"alg":"sealedbox-v1-x25519-xsalsa20poly1305","keyId":"body_20260827_01","ciphertext":"..."}
Read request:
GET /api/v1/payments?orderNo=P202608270001 HTTP/1.1
Merchant-Access-Key: mak_live_test
Signature-Input: merchant=("@method" "@path" "@query" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"
Signature: merchant=:H1IK1NGk7htlA/O3JZhndbDGkl/gB/a3Hw4wtlNGXoG+mHYvtSGUWBUs0fJOc0MpNrJ5KYGqnohDDXd9jAxYCg==:
The signatures above were produced with the test private key seed ERERERERERERERERERERERERERERERERERERERERERE= (base64) and public key 0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc=. Use them only to validate your implementation.
Code Examples
The examples below implement request signing and body encryption without the SDK. Each one exposes signWrite for POST requests and signRead for GET requests, and depends only on the language's standard library plus a libsodium binding (Go uses golang.org/x/crypto/nacl/box; Java uses lazysodium-java; Ruby uses rbnacl).
The credentials in the examples are the protocol test vector keys. With created=1787803200 and nonce b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a, signRead("/api/v1/payments", "orderNo=P202608270001") must produce the Signature shown in the read example above. Use that check to validate your port before switching to production keys.
- Go
- JavaScript
- Python
- PHP
- Java
- Ruby
package main
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/nacl/box"
)
// Credentials from the merchant portal.
const (
accessKey = "mak_live_test"
merchantPrivateKeyBase64 = "ERERERERERERERERERERERERERERERERERERERERERE=" // Ed25519 seed (32 bytes) or full key (64 bytes)
platformBodyKeyID = "body_test_1"
platformBodyPublicKeyB64 = "ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ=" // platform X25519 public key
)
var writeCovered = []string{"@method", "@path", "content-type", "content-encryption", "content-digest", "idempotency-key", "merchant-access-key"}
var readCovered = []string{"@method", "@path", "@query", "merchant-access-key"}
// Keys are decoded once at startup, not per request.
var (
merchantPrivateKey = loadPrivateKey(merchantPrivateKeyBase64)
platformBodyPublicKey = mustDecodeKey32(platformBodyPublicKeyB64)
)
func loadPrivateKey(b64 string) ed25519.PrivateKey {
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
panic(err)
}
if len(raw) == ed25519.SeedSize {
return ed25519.NewKeyFromSeed(raw)
}
return ed25519.PrivateKey(raw)
}
func mustDecodeKey32(b64 string) *[32]byte {
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(raw) != 32 {
panic("invalid 32-byte key")
}
var key [32]byte
copy(key[:], raw)
return &key
}
// uuidV4 returns a lowercase random UUID v4 (used for nonce and Idempotency-Key).
func uuidV4() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// contentDigest returns the RFC 9530 Content-Digest header value for the wire body.
func contentDigest(body []byte) string {
sum := sha256.Sum256(body)
return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
}
// sealBody encrypts the business JSON to the platform X25519 public key (libsodium sealed box).
func sealBody(plaintext []byte) []byte {
ciphertext, err := box.SealAnonymous(nil, plaintext, platformBodyPublicKey, rand.Reader)
if err != nil {
panic(err)
}
envelope, _ := json.Marshal(map[string]any{
"version": 1,
"alg": "sealedbox-v1-x25519-xsalsa20poly1305",
"keyId": platformBodyKeyID,
"ciphertext": base64.StdEncoding.EncodeToString(ciphertext),
})
return envelope
}
// signatureParams builds the value after "merchant=" in Signature-Input.
func signatureParams(covered []string, created int64, nonce string) string {
quoted := make([]string, len(covered))
for i, c := range covered {
quoted[i] = strconv.Quote(c)
}
return "(" + strings.Join(quoted, " ") + ")" +
";created=" + strconv.FormatInt(created, 10) +
";expires=" + strconv.FormatInt(created+300, 10) +
";nonce=" + strconv.Quote(nonce) +
";alg=\"ed25519\""
}
// signatureBase joins one line per covered component plus the @signature-params line.
// Derived components (@method, @path, @query) come from derived; header components come from headers.
func signatureBase(covered []string, derived, headers map[string]string, params string) []byte {
lines := make([]string, 0, len(covered)+1)
for _, c := range covered {
value, ok := derived[c]
if !ok {
value = headers[http.CanonicalHeaderKey(c)]
}
lines = append(lines, strconv.Quote(c)+": "+value)
}
lines = append(lines, `"@signature-params": `+params)
return []byte(strings.Join(lines, "\n"))
}
func sign(base []byte) string {
sig := ed25519.Sign(merchantPrivateKey, base)
return "merchant=:" + base64.StdEncoding.EncodeToString(sig) + ":"
}
// SignWrite returns the wire body and headers for POST {path} with the given business JSON.
func SignWrite(path string, businessJSON []byte, created int64, nonce, idempotencyKey string) ([]byte, map[string]string) {
body := sealBody(businessJSON)
headers := map[string]string{
"Content-Type": "application/json",
"Content-Encryption": "sealedbox-v1-x25519-xsalsa20poly1305",
"Content-Digest": contentDigest(body),
"Idempotency-Key": idempotencyKey,
"Merchant-Access-Key": accessKey,
}
params := signatureParams(writeCovered, created, nonce)
base := signatureBase(writeCovered, map[string]string{"@method": "POST", "@path": path}, headers, params)
headers["Signature-Input"] = "merchant=" + params
headers["Signature"] = sign(base)
return body, headers
}
// SignRead returns the headers for GET {path}?{rawQuery}. rawQuery must be sent exactly as signed.
func SignRead(path, rawQuery string, created int64, nonce string) map[string]string {
params := signatureParams(readCovered, created, nonce)
headers := map[string]string{"Merchant-Access-Key": accessKey}
base := signatureBase(readCovered, map[string]string{"@method": "GET", "@path": path, "@query": "?" + rawQuery}, headers, params)
headers["Signature-Input"] = "merchant=" + params
headers["Signature"] = sign(base)
return headers
}
func main() {
now := time.Now().Unix()
body, headers := SignWrite("/api/v1/payments", []byte(`{"merchantOrderNo":"M202605060001","currency":"BRL","amount":"250.00","paymentMethod":{"code":"PIX"},"webhookUrl":"https://merchant.example/webhook/payment"}`), now, uuidV4(), uuidV4())
fmt.Println(string(body))
fmt.Println(headers)
fmt.Println(SignRead("/api/v1/payments", "merchantOrderNo=M202605060001", now, uuidV4()))
}
import { createHash, randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
const sodium = createRequire(import.meta.url)('libsodium-wrappers');
// Credentials from the merchant portal.
const accessKey = 'mak_live_test';
const merchantPrivateKeyBase64 = 'ERERERERERERERERERERERERERERERERERERERERERE='; // Ed25519 seed (32 bytes) or full key (64 bytes)
const platformBodyKeyId = 'body_test_1';
const platformBodyPublicKeyBase64 = 'ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ='; // platform X25519 public key
const WRITE_COVERED = ['@method', '@path', 'content-type', 'content-encryption', 'content-digest', 'idempotency-key', 'merchant-access-key'];
const READ_COVERED = ['@method', '@path', '@query', 'merchant-access-key'];
await sodium.ready;
// Keys are decoded once at startup, not per request.
const merchantPrivateKey = (() => {
const raw = Buffer.from(merchantPrivateKeyBase64, 'base64');
return raw.length === 32 ? sodium.crypto_sign_seed_keypair(raw).privateKey : new Uint8Array(raw);
})();
const platformBodyPublicKey = Buffer.from(platformBodyPublicKeyBase64, 'base64');
// RFC 9530 Content-Digest of the wire body.
export function contentDigest(body) {
return `sha-256=:${createHash('sha256').update(body).digest('base64')}:`;
}
// Encrypt the business JSON to the platform X25519 public key (libsodium sealed box).
export function sealBody(plaintext) {
const ciphertext = sodium.crypto_box_seal(Buffer.from(plaintext, 'utf8'), platformBodyPublicKey);
return Buffer.from(JSON.stringify({
version: 1,
alg: 'sealedbox-v1-x25519-xsalsa20poly1305',
keyId: platformBodyKeyId,
ciphertext: Buffer.from(ciphertext).toString('base64'),
}), 'utf8');
}
// Value after "merchant=" in Signature-Input.
function signatureParams(covered, created, nonce) {
return `(${covered.map((c) => JSON.stringify(c)).join(' ')})`
+ `;created=${created};expires=${created + 300};nonce=${JSON.stringify(nonce)};alg="ed25519"`;
}
// One line per covered component plus the @signature-params line, joined by \n.
// Derived components (@method, @path, @query) come from derived; header components are looked up in headers.
function signatureBase(covered, derived, headers, params) {
const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
const lines = covered.map((c) => `${JSON.stringify(c)}: ${derived[c] ?? lower[c]}`);
lines.push(`"@signature-params": ${params}`);
return Buffer.from(lines.join('\n'), 'utf8');
}
function sign(base) {
const sig = sodium.crypto_sign_detached(base, merchantPrivateKey);
return `merchant=:${Buffer.from(sig).toString('base64')}:`;
}
// Wire body and headers for POST {path} with the given business JSON string.
export function signWrite(path, businessJson, created = Math.floor(Date.now() / 1000), nonce = randomUUID(), idempotencyKey = randomUUID()) {
const body = sealBody(businessJson);
const headers = {
'Content-Type': 'application/json',
'Content-Encryption': 'sealedbox-v1-x25519-xsalsa20poly1305',
'Content-Digest': contentDigest(body),
'Idempotency-Key': idempotencyKey,
'Merchant-Access-Key': accessKey,
};
const params = signatureParams(WRITE_COVERED, created, nonce);
const base = signatureBase(WRITE_COVERED, { '@method': 'POST', '@path': path }, headers, params);
headers['Signature-Input'] = `merchant=${params}`;
headers['Signature'] = sign(base);
return { body, headers };
}
// Headers for GET {path}?{rawQuery}. rawQuery must be sent exactly as signed.
export function signRead(path, rawQuery, created = Math.floor(Date.now() / 1000), nonce = randomUUID()) {
const params = signatureParams(READ_COVERED, created, nonce);
const headers = { 'Merchant-Access-Key': accessKey };
const base = signatureBase(READ_COVERED, { '@method': 'GET', '@path': path, '@query': `?${rawQuery}` }, headers, params);
headers['Signature-Input'] = `merchant=${params}`;
headers.Signature = sign(base);
return headers;
}
// Usage
const { body, headers } = signWrite('/api/v1/payments', JSON.stringify({ merchantOrderNo: 'M202605060001', currency: 'BRL', amount: '250.00', paymentMethod: { code: 'PIX' }, webhookUrl: 'https://merchant.example/webhook/payment' }));
// await fetch('https://panama.deepayment.com/api/v1/payments', { method: 'POST', headers, body });
import base64
import hashlib
import json
import time
import uuid
from nacl.public import PublicKey, SealedBox
from nacl.signing import SigningKey
# Credentials from the merchant portal.
ACCESS_KEY = "mak_live_test"
MERCHANT_PRIVATE_KEY_B64 = "ERERERERERERERERERERERERERERERERERERERERERE=" # Ed25519 seed (32 bytes) or full key (64 bytes)
PLATFORM_BODY_KEY_ID = "body_test_1"
PLATFORM_BODY_PUBLIC_KEY_B64 = "ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ=" # platform X25519 public key
WRITE_COVERED = ("@method", "@path", "content-type", "content-encryption", "content-digest", "idempotency-key", "merchant-access-key")
READ_COVERED = ("@method", "@path", "@query", "merchant-access-key")
# Keys are decoded once at import, not per request.
SIGNING_KEY = SigningKey(base64.b64decode(MERCHANT_PRIVATE_KEY_B64)[:32]) # PyNaCl takes the 32-byte seed
SEALED_BOX = SealedBox(PublicKey(base64.b64decode(PLATFORM_BODY_PUBLIC_KEY_B64)))
def content_digest(body: bytes) -> str:
"""RFC 9530 Content-Digest of the wire body."""
return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
def seal_body(plaintext: bytes) -> bytes:
"""Encrypt the business JSON to the platform X25519 public key (libsodium sealed box)."""
envelope = {
"version": 1,
"alg": "sealedbox-v1-x25519-xsalsa20poly1305",
"keyId": PLATFORM_BODY_KEY_ID,
"ciphertext": base64.b64encode(SEALED_BOX.encrypt(plaintext)).decode(),
}
return json.dumps(envelope, separators=(",", ":")).encode()
def signature_params(covered, created: int, nonce: str) -> str:
"""Value after 'merchant=' in Signature-Input."""
components = " ".join(json.dumps(c) for c in covered)
return f"({components});created={created};expires={created + 300};nonce={json.dumps(nonce)};alg=\"ed25519\""
def signature_base(covered, derived: dict, headers: dict, params: str) -> bytes:
"""One line per covered component plus the @signature-params line, joined by \\n.
Derived components (@method, @path, @query) come from derived; header components are looked up in headers.
"""
lower = {k.lower(): v for k, v in headers.items()}
lines = [f"{json.dumps(c)}: {derived.get(c, lower.get(c))}" for c in covered]
lines.append(f'"@signature-params": {params}')
return "\n".join(lines).encode()
def sign(base: bytes) -> str:
sig = SIGNING_KEY.sign(base).signature
return "merchant=:" + base64.b64encode(sig).decode() + ":"
def sign_write(path: str, business_json: bytes, created: int | None = None, nonce: str | None = None, idempotency_key: str | None = None):
"""Wire body and headers for POST {path} with the given business JSON bytes."""
created = created or int(time.time())
nonce = nonce or str(uuid.uuid4())
body = seal_body(business_json)
headers = {
"Content-Type": "application/json",
"Content-Encryption": "sealedbox-v1-x25519-xsalsa20poly1305",
"Content-Digest": content_digest(body),
"Idempotency-Key": idempotency_key or str(uuid.uuid4()),
"Merchant-Access-Key": ACCESS_KEY,
}
params = signature_params(WRITE_COVERED, created, nonce)
base = signature_base(WRITE_COVERED, {"@method": "POST", "@path": path}, headers, params)
headers["Signature-Input"] = "merchant=" + params
headers["Signature"] = sign(base)
return body, headers
def sign_read(path: str, raw_query: str, created: int | None = None, nonce: str | None = None) -> dict:
"""Headers for GET {path}?{raw_query}. raw_query must be sent exactly as signed."""
created = created or int(time.time())
nonce = nonce or str(uuid.uuid4())
params = signature_params(READ_COVERED, created, nonce)
headers = {"Merchant-Access-Key": ACCESS_KEY}
base = signature_base(READ_COVERED, {"@method": "GET", "@path": path, "@query": "?" + raw_query}, headers, params)
headers["Signature-Input"] = "merchant=" + params
headers["Signature"] = sign(base)
return headers
if __name__ == "__main__":
body, headers = sign_write("/api/v1/payments", json.dumps({"merchantOrderNo": "M202605060001", "currency": "BRL", "amount": "250.00", "paymentMethod": {"code": "PIX"}, "webhookUrl": "https://merchant.example/webhook/payment"}).encode())
# requests.post("https://panama.deepayment.com/api/v1/payments", data=body, headers=headers)
<?php
// Requires the sodium extension (bundled with PHP 7.2+).
// Credentials from the merchant portal.
const ACCESS_KEY = 'mak_live_test';
const MERCHANT_PRIVATE_KEY_B64 = 'ERERERERERERERERERERERERERERERERERERERERERE='; // Ed25519 seed (32 bytes) or full key (64 bytes)
const PLATFORM_BODY_KEY_ID = 'body_test_1';
const PLATFORM_BODY_PUBLIC_KEY_B64 = 'ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ='; // platform X25519 public key
const WRITE_COVERED = ['@method', '@path', 'content-type', 'content-encryption', 'content-digest', 'idempotency-key', 'merchant-access-key'];
const READ_COVERED = ['@method', '@path', '@query', 'merchant-access-key'];
// Decoded once per process, not per request.
function merchantSecretKey(): string
{
static $secretKey = null;
if ($secretKey === null) {
$raw = base64_decode(MERCHANT_PRIVATE_KEY_B64, true);
$secretKey = strlen($raw) === SODIUM_CRYPTO_SIGN_SEEDBYTES
? sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair($raw))
: $raw;
}
return $secretKey;
}
function uuidV4(): string
{
$b = random_bytes(16);
$b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
$b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
}
// RFC 9530 Content-Digest of the wire body.
function contentDigest(string $body): string
{
return 'sha-256=:' . base64_encode(hash('sha256', $body, true)) . ':';
}
// Encrypt the business JSON to the platform X25519 public key (libsodium sealed box).
function sealBody(string $plaintext): string
{
$ciphertext = sodium_crypto_box_seal($plaintext, base64_decode(PLATFORM_BODY_PUBLIC_KEY_B64, true));
return json_encode([
'version' => 1,
'alg' => 'sealedbox-v1-x25519-xsalsa20poly1305',
'keyId' => PLATFORM_BODY_KEY_ID,
'ciphertext' => base64_encode($ciphertext),
], JSON_UNESCAPED_SLASHES);
}
// Value after "merchant=" in Signature-Input.
function signatureParams(array $covered, int $created, string $nonce): string
{
$components = implode(' ', array_map(fn($c) => json_encode($c), $covered));
return "({$components});created={$created};expires=" . ($created + 300) . ';nonce=' . json_encode($nonce) . ';alg="ed25519"';
}
// One line per covered component plus the @signature-params line, joined by "\n".
// Derived components (@method, @path, @query) come from $derived; header components are looked up in $headers.
function signatureBase(array $covered, array $derived, array $headers, string $params): string
{
$lower = array_change_key_case($headers, CASE_LOWER);
$lines = array_map(fn($c) => json_encode($c) . ': ' . ($derived[$c] ?? $lower[$c]), $covered);
$lines[] = '"@signature-params": ' . $params;
return implode("\n", $lines);
}
function sign(string $base): string
{
return 'merchant=:' . base64_encode(sodium_crypto_sign_detached($base, merchantSecretKey())) . ':';
}
// Wire body and headers for POST {$path} with the given business JSON string.
function signWrite(string $path, string $businessJson, ?int $created = null, ?string $nonce = null, ?string $idempotencyKey = null): array
{
$created ??= time();
$nonce ??= uuidV4();
$body = sealBody($businessJson);
$headers = [
'Content-Type' => 'application/json',
'Content-Encryption' => 'sealedbox-v1-x25519-xsalsa20poly1305',
'Content-Digest' => contentDigest($body),
'Idempotency-Key' => $idempotencyKey ?? uuidV4(),
'Merchant-Access-Key' => ACCESS_KEY,
];
$params = signatureParams(WRITE_COVERED, $created, $nonce);
$base = signatureBase(WRITE_COVERED, ['@method' => 'POST', '@path' => $path], $headers, $params);
$headers['Signature-Input'] = 'merchant=' . $params;
$headers['Signature'] = sign($base);
return [$body, $headers];
}
// Headers for GET {$path}?{$rawQuery}. $rawQuery must be sent exactly as signed.
function signRead(string $path, string $rawQuery, ?int $created = null, ?string $nonce = null): array
{
$created ??= time();
$nonce ??= uuidV4();
$params = signatureParams(READ_COVERED, $created, $nonce);
$headers = ['Merchant-Access-Key' => ACCESS_KEY];
$base = signatureBase(READ_COVERED, ['@method' => 'GET', '@path' => $path, '@query' => '?' . $rawQuery], $headers, $params);
$headers['Signature-Input'] = 'merchant=' . $params;
$headers['Signature'] = sign($base);
return $headers;
}
// Usage
[$body, $headers] = signWrite('/api/v1/payments', json_encode([
'merchantOrderNo' => 'M202605060001', 'currency' => 'BRL', 'amount' => '250.00',
'paymentMethod' => ['code' => 'PIX'], 'webhookUrl' => 'https://merchant.example/webhook/payment',
], JSON_UNESCAPED_SLASHES));
// Send $body with $headers via curl to https://panama.deepayment.com/api/v1/payments
// Dependency: com.goterl:lazysodium-java (libsodium binding). Base64/UUID/SHA-256 come from the JDK.
import com.goterl.lazysodium.LazySodiumJava;
import com.goterl.lazysodium.SodiumJava;
import com.goterl.lazysodium.interfaces.Box;
import com.goterl.lazysodium.interfaces.Sign;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
public final class MerchantSigner {
// Credentials from the merchant portal.
static final String ACCESS_KEY = "mak_live_test";
static final String MERCHANT_PRIVATE_KEY_B64 = "ERERERERERERERERERERERERERERERERERERERERERE="; // Ed25519 seed (32 bytes) or full key (64 bytes)
static final String PLATFORM_BODY_KEY_ID = "body_test_1";
static final String PLATFORM_BODY_PUBLIC_KEY_B64 = "ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ="; // platform X25519 public key
static final List<String> WRITE_COVERED = List.of("@method", "@path", "content-type", "content-encryption", "content-digest", "idempotency-key", "merchant-access-key");
static final List<String> READ_COVERED = List.of("@method", "@path", "@query", "merchant-access-key");
static final LazySodiumJava SODIUM = new LazySodiumJava(new SodiumJava());
static final Base64.Encoder B64 = Base64.getEncoder();
// Keys are decoded once at class load, not per request.
static final byte[] MERCHANT_SECRET_KEY = merchantSecretKey();
static final byte[] PLATFORM_BODY_PUBLIC_KEY = Base64.getDecoder().decode(PLATFORM_BODY_PUBLIC_KEY_B64);
static byte[] merchantSecretKey() {
byte[] raw = Base64.getDecoder().decode(MERCHANT_PRIVATE_KEY_B64);
if (raw.length == Sign.SEEDBYTES) {
byte[] pk = new byte[Sign.PUBLICKEYBYTES];
byte[] sk = new byte[Sign.SECRETKEYBYTES];
SODIUM.cryptoSignSeedKeypair(pk, sk, raw);
return sk;
}
return raw;
}
/** RFC 9530 Content-Digest of the wire body. */
static String contentDigest(byte[] body) throws Exception {
return "sha-256=:" + B64.encodeToString(MessageDigest.getInstance("SHA-256").digest(body)) + ":";
}
/** Encrypt the business JSON to the platform X25519 public key (libsodium sealed box). */
static byte[] sealBody(byte[] plaintext) {
byte[] ciphertext = new byte[plaintext.length + Box.SEALBYTES];
if (!SODIUM.cryptoBoxSeal(ciphertext, plaintext, plaintext.length, PLATFORM_BODY_PUBLIC_KEY)) {
throw new IllegalStateException("seal failed");
}
String envelope = "{\"version\":1,\"alg\":\"sealedbox-v1-x25519-xsalsa20poly1305\",\"keyId\":\""
+ PLATFORM_BODY_KEY_ID + "\",\"ciphertext\":\"" + B64.encodeToString(ciphertext) + "\"}";
return envelope.getBytes(StandardCharsets.UTF_8);
}
static String quote(String value) { return "\"" + value + "\""; } // protocol values contain no characters that need escaping
/** Value after "merchant=" in Signature-Input. */
static String signatureParams(List<String> covered, long created, String nonce) {
StringJoiner components = new StringJoiner(" ", "(", ")");
covered.forEach(c -> components.add(quote(c)));
return components + ";created=" + created + ";expires=" + (created + 300) + ";nonce=" + quote(nonce) + ";alg=\"ed25519\"";
}
/**
* One line per covered component plus the @signature-params line, joined by \n.
* Derived components (@method, @path, @query) come from derived; header components are looked up in headers.
*/
static byte[] signatureBase(List<String> covered, Map<String, String> derived, Map<String, String> headers, String params) {
Map<String, String> lower = new HashMap<>();
headers.forEach((k, v) -> lower.put(k.toLowerCase(Locale.ROOT), v));
StringJoiner lines = new StringJoiner("\n");
covered.forEach(c -> lines.add(quote(c) + ": " + derived.getOrDefault(c, lower.get(c))));
lines.add("\"@signature-params\": " + params);
return lines.toString().getBytes(StandardCharsets.UTF_8);
}
static String sign(byte[] base) {
byte[] sig = new byte[Sign.BYTES];
SODIUM.cryptoSignDetached(sig, base, base.length, MERCHANT_SECRET_KEY);
return "merchant=:" + B64.encodeToString(sig) + ":";
}
/** Wire body and headers for POST {path} with the given business JSON. */
static Map.Entry<byte[], Map<String, String>> signWrite(String path, byte[] businessJson) throws Exception {
long created = System.currentTimeMillis() / 1000;
byte[] body = sealBody(businessJson);
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Content-Type", "application/json");
headers.put("Content-Encryption", "sealedbox-v1-x25519-xsalsa20poly1305");
headers.put("Content-Digest", contentDigest(body));
headers.put("Idempotency-Key", UUID.randomUUID().toString());
headers.put("Merchant-Access-Key", ACCESS_KEY);
String params = signatureParams(WRITE_COVERED, created, UUID.randomUUID().toString());
byte[] base = signatureBase(WRITE_COVERED, Map.of("@method", "POST", "@path", path), headers, params);
headers.put("Signature-Input", "merchant=" + params);
headers.put("Signature", sign(base));
return Map.entry(body, headers);
}
/** Headers for GET {path}?{rawQuery}. rawQuery must be sent exactly as signed. */
static Map<String, String> signRead(String path, String rawQuery) {
long created = System.currentTimeMillis() / 1000;
String params = signatureParams(READ_COVERED, created, UUID.randomUUID().toString());
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Merchant-Access-Key", ACCESS_KEY);
byte[] base = signatureBase(READ_COVERED, Map.of("@method", "GET", "@path", path, "@query", "?" + rawQuery), headers, params);
headers.put("Signature-Input", "merchant=" + params);
headers.put("Signature", sign(base));
return headers;
}
}
# Requires the rbnacl gem (libsodium binding): gem install rbnacl
require 'rbnacl'
require 'base64'
require 'digest'
require 'json'
require 'securerandom'
# Credentials from the merchant portal.
ACCESS_KEY = 'mak_live_test'
MERCHANT_PRIVATE_KEY_B64 = 'ERERERERERERERERERERERERERERERERERERERERERE=' # Ed25519 seed (32 bytes) or full key (64 bytes)
PLATFORM_BODY_KEY_ID = 'body_test_1'
PLATFORM_BODY_PUBLIC_KEY_B64 = 'ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ=' # platform X25519 public key
WRITE_COVERED = %w[@method @path content-type content-encryption content-digest idempotency-key merchant-access-key].freeze
READ_COVERED = %w[@method @path @query merchant-access-key].freeze
# Keys are decoded once at load, not per request.
SIGNING_KEY = RbNaCl::SigningKey.new(Base64.strict_decode64(MERCHANT_PRIVATE_KEY_B64)[0, 32]) # rbnacl takes the 32-byte seed
SEALED_BOX = RbNaCl::SealedBox.new(RbNaCl::PublicKey.new(Base64.strict_decode64(PLATFORM_BODY_PUBLIC_KEY_B64)))
# RFC 9530 Content-Digest of the wire body.
def content_digest(body)
"sha-256=:#{Base64.strict_encode64(Digest::SHA256.digest(body))}:"
end
# Encrypt the business JSON to the platform X25519 public key (libsodium sealed box).
def seal_body(plaintext)
JSON.generate(
version: 1,
alg: 'sealedbox-v1-x25519-xsalsa20poly1305',
keyId: PLATFORM_BODY_KEY_ID,
ciphertext: Base64.strict_encode64(SEALED_BOX.encrypt(plaintext))
)
end
# Value after "merchant=" in Signature-Input.
def signature_params(covered, created, nonce)
components = covered.map { |c| c.to_json }.join(' ')
"(#{components});created=#{created};expires=#{created + 300};nonce=#{nonce.to_json};alg=\"ed25519\""
end
# One line per covered component plus the @signature-params line, joined by "\n".
# Derived components (@method, @path, @query) come from derived; header components are looked up in headers.
def signature_base(covered, derived, headers, params)
lower = headers.to_h { |k, v| [k.downcase, v] }
lines = covered.map { |c| "#{c.to_json}: #{derived.fetch(c) { lower[c] }}" }
lines << "\"@signature-params\": #{params}"
lines.join("\n")
end
def sign(base)
"merchant=:#{Base64.strict_encode64(SIGNING_KEY.sign(base))}:"
end
# Wire body and headers for POST {path} with the given business JSON string.
def sign_write(path, business_json, created: Time.now.to_i, nonce: SecureRandom.uuid, idempotency_key: SecureRandom.uuid)
body = seal_body(business_json)
headers = {
'Content-Type' => 'application/json',
'Content-Encryption' => 'sealedbox-v1-x25519-xsalsa20poly1305',
'Content-Digest' => content_digest(body),
'Idempotency-Key' => idempotency_key,
'Merchant-Access-Key' => ACCESS_KEY
}
params = signature_params(WRITE_COVERED, created, nonce)
base = signature_base(WRITE_COVERED, { '@method' => 'POST', '@path' => path }, headers, params)
headers['Signature-Input'] = "merchant=#{params}"
headers['Signature'] = sign(base)
[body, headers]
end
# Headers for GET {path}?{raw_query}. raw_query must be sent exactly as signed.
def sign_read(path, raw_query, created: Time.now.to_i, nonce: SecureRandom.uuid)
params = signature_params(READ_COVERED, created, nonce)
headers = { 'Merchant-Access-Key' => ACCESS_KEY }
base = signature_base(READ_COVERED, { '@method' => 'GET', '@path' => path, '@query' => "?#{raw_query}" }, headers, params)
headers['Signature-Input'] = "merchant=#{params}"
headers['Signature'] = sign(base)
headers
end
# Usage
body, headers = sign_write('/api/v1/payments', JSON.generate(
merchantOrderNo: 'M202605060001', currency: 'BRL', amount: '250.00',
paymentMethod: { code: 'PIX' }, webhookUrl: 'https://merchant.example/webhook/payment'
))
# Net::HTTP.post(URI('https://panama.deepayment.com/api/v1/payments'), body, headers)
Send the returned body bytes unchanged. Any re-serialization of the envelope after signing invalidates Content-Digest and the signature.
Server Verification Order
- Request shape: method, required and forbidden headers, single-valued headers.
Content-Digestrecomputed over the wire body.Signature-Inputparsed;alg,nonce, time window, and covered components validated.- Ed25519 signature verified with the merchant public key over the reconstructed signature base.
- Envelope parsed and decrypted; the plaintext is then handled as the business request.
Any failure returns HTTP 401 with msg UNAUTHORIZED and does not reach business processing.
Implementation Checklist
Sign the wire body
Compute Content-Digest and the signature over the encrypted envelope bytes exactly as sent.
Keep the query verbatim
@query is ? plus the raw query. Never sort or re-encode.
Sync clocks
Keep server time accurate. Signatures older than 300 seconds are rejected.
Protect the private key
The Ed25519 private key stays on your servers. Never log it, ship it to clients, or send it to DEEPayment.