Skip to main content

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
created and expires are Unix seconds. expires - created must be at most 300 seconds. The server rejects expired signatures.
Replay protection
nonce is a UUID v4 regenerated for every HTTP request. A reused nonce is rejected.
Body encryption
POST bodies are a sealed box envelope, Content-Encryption: sealedbox-v1-x25519-xsalsa20poly1305. GET requests 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.

TypeHTTPBodyQueryPurpose
WritePOSTSealed box envelope, Content-Type: application/jsonNot allowedCreate, cancel, confirm, update
ReadGETNot allowedPublic identifiers onlyQuery 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​

HeaderWrite (POST)Read (GET)Value
Merchant-Access-KeyRequired, signedRequired, signedYour Access Key
Content-TypeRequired, signedNot sentapplication/json
Content-EncryptionRequired, signedNot sentsealedbox-v1-x25519-xsalsa20poly1305
Content-DigestRequired, signedNot sentsha-256=:<base64(SHA-256(wire body))>:
Idempotency-KeyRequired, signedNot sentUUID v4, fresh per request; carried for tracing, never used for deduplication
Signature-InputRequiredRequiredCovered components and parameters, label merchant
SignatureRequiredRequiredmerchant=:<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"
created

Unix seconds at signing time.

expires

Unix seconds. At most created + 300.

nonce

UUID v4, new for every HTTP request.

alg

Fixed 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"
  • @method is the uppercase HTTP method.
  • @path is the URL path, for example /api/v1/payments.
  • @query is ? 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)"
}
version

Fixed 1.

alg

Fixed sealedbox-v1-x25519-xsalsa20poly1305. Must equal the Content-Encryption header.

keyId

The platform body key id shown in the merchant portal. Selects the platform private key used for decryption.

ciphertext

Standard 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, or tag is transmitted.
  • The envelope must contain exactly these four fields. Unknown fields or trailing content are rejected.
  • Content-Digest is 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.

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()))
}

Send the returned body bytes unchanged. Any re-serialization of the envelope after signing invalidates Content-Digest and the signature.

Server Verification Order​

  1. Request shape: method, required and forbidden headers, single-valued headers.
  2. Content-Digest recomputed over the wire body.
  3. Signature-Input parsed; alg, nonce, time window, and covered components validated.
  4. Ed25519 signature verified with the merchant public key over the reconstructed signature base.
  5. 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.