Skip to content

API authentication: Ed25519 v2

Merchant requests to /v2/acquiring, /v2/funds, /v2/cards and /v2/payouts use Ed25519 signatures. This is request signing, not payload encryption; use HTTPS.

Create a credential

  1. Generate an Ed25519 key pair on your backend. Keep the private key private.
  2. Upload the public key in the merchant console and select permissions and an IP allowlist.
  3. Infini returns a 32-character Base58 Key ID. It identifies the database credential bound to your merchant and public key; it is not the public key or a secret.
  4. Send the Key ID with each request and sign with the corresponding private key. Infini stores the public key and verifies the signature; it does not generate or return a shared secret.

Permissions fund.withdraw, card.create, card.reveal and payout.write require a non-empty IP allowlist matching your backend's public egress IP. Disabling a credential rejects requests until it is enabled again; deleting it permanently removes the credential.

Use OpenSSL 1.1.1 or newer with Ed25519 support (for example OpenSSL 3). Some system LibreSSL installations do not support this command; check openssl version and use an Ed25519-capable OpenSSL installation.

umask 077
openssl genpkey -algorithm ED25519 -out merchant-private.pem
openssl pkey -in merchant-private.pem -pubout -out merchant-public.pem

The private key is unencrypted PKCS#8 PEM, wrapped in -----BEGIN PRIVATE KEY----- / -----END PRIVATE KEY-----. The uploaded public key is SPKI/PKIX PEM, wrapped in -----BEGIN PUBLIC KEY----- / -----END PUBLIC KEY-----. A PEM wrapper alone is insufficient: the encoded key must parse as Ed25519. Do not supply an OpenSSH key, a raw 32-byte seed, or a Base64 string without its PEM wrapper to the example loader.

A secrets manager may store the same complete PEM text. Decode its JSON string once if stored in JSON, preserving actual line breaks. Pass the resulting PEM bytes through the same PKCS#8 parser as a file. Do not Base64-decode the whole PEM string or blindly replace escaped newlines. Never upload the private key to Infini or this documentation site.

Required headers

HeaderValue
X-API-Key-IDKey ID returned after uploading the public key
X-API-TimestampCurrent Unix timestamp in seconds, decimal integer
X-API-Nonce16 cryptographically random bytes encoded as Base64URL without padding
DigestSHA-256= + standard padded Base64 of SHA-256 of the exact body bytes
AuthorizationEd25519 signature="<standard padded Base64 signature>"

All five headers are required, including on GET. Hash an empty byte sequence when there is no body. Date and the legacy Signature keyId=... header are not used in v2. Timestamp tolerance is ±300 seconds. Generate a fresh nonce, timestamp and signature on every retry, while retaining the same business request_id for an idempotent operation.

Signing string

Join these seven lines with LF (\n), including one final LF:

INFINI-OPENAPI-V2
{key_id}
{UPPERCASE_METHOD}
{escaped_path_and_raw_query}
{timestamp}
{nonce}
SHA-256={body_hash_base64}

INFINI-OPENAPI-V2 is a fixed protocol identifier; copy it exactly. Sign the UTF-8 bytes using plain Ed25519, not Ed25519ph and not a separate SHA-256 hash of the signing string. Encode the resulting 64-byte signature with standard Base64.

The request target includes /v2/... and the complete query string, but no scheme or host. For example: /v2/acquiring/order?order_id=.... Do not reorder query parameters, change percent-encoding, or rewrite the path after signing.

Serialize JSON once, hash those bytes, and send those same bytes. Infini hashes the received raw body without re-marshalling JSON. Different JSON key order or whitespace is acceptable only if the digest and signature are recalculated for the bytes actually sent. The Digest is included in the signature, binding the body to the request.

Integrate with your backend

Add the clients below to your merchant backend and call them from your business code. At service startup, read the API origin and Key ID from configuration and load the complete PKCS#8 PEM from a secrets manager or mounted key file. Initialize and reuse the client. Keep private keys on your backend, never in a browser, mobile app or application logs.

Business flow: generate and persist the business request_id, construct the request body, serialize it once, let the client sign and send it, check both HTTP status and business code, then persist the result. Each call generates a fresh timestamp and nonce. No command-line arguments or --send switch are needed.

These are integration examples: supply apiOrigin / api_origin, keyID / key_id and the PEM contents from application configuration or your secret service, and supply order data from your business system. Use the origin and credentials assigned separately for Sandbox and production.

Go backend integration

Standard library only. Place this file in an infini package and import it using your project module path.

Call from your business service:

// At startup: privateKeyPEM contains the complete PEM bytes from your secret store.
client, err := infini.NewClient(apiOrigin, keyID, privateKeyPEM)
if err != nil { return err }

// In your order service: order contains the persisted business request_id.
body, err := json.Marshal(order)
if err != nil { return err }
resp, err := client.Do(ctx, "POST", "/v2/acquiring/order", body)
if err != nil { return err }
defer resp.Body.Close()
// Decode resp.Body and check resp.StatusCode AND the JSON business code.

Complete client source

package infini

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/pem"
	"fmt"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"
)

func edLoadPrivateKey(data []byte) (ed25519.PrivateKey, error) {
	block, rest := pem.Decode(bytes.TrimSpace(data))
	if block == nil || block.Type != "PRIVATE KEY" || len(block.Headers) != 0 || len(bytes.TrimSpace(rest)) != 0 {
		return nil, fmt.Errorf("expected one unencrypted PKCS#8 PEM block: BEGIN PRIVATE KEY")
	}
	parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
	if err != nil {
		return nil, fmt.Errorf("invalid PKCS#8 private key: %w", err)
	}
	key, ok := parsed.(ed25519.PrivateKey)
	if !ok {
		return nil, fmt.Errorf("private key must be Ed25519")
	}
	return key, nil
}

func edSignRequest(req *http.Request, body []byte, keyID string, key ed25519.PrivateKey, now time.Time) (string, error) {
	nonceBytes := make([]byte, 16)
	if _, err := rand.Read(nonceBytes); err != nil {
		return "", err
	}
	nonce := base64.RawURLEncoding.EncodeToString(nonceBytes)
	timestamp := strconv.FormatInt(now.Unix(), 10)
	hash := sha256.Sum256(body)
	digest := "SHA-256=" + base64.StdEncoding.EncodeToString(hash[:])
	// Sign the exact escaped path and raw query sent on the wire, with a final LF.
	payload := strings.Join([]string{"INFINI-OPENAPI-V2", keyID, req.Method, req.URL.RequestURI(), timestamp, nonce, digest}, "\n") + "\n"
	signature := base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(payload)))
	req.Header.Set("X-API-Key-ID", keyID)
	req.Header.Set("X-API-Timestamp", timestamp)
	req.Header.Set("X-API-Nonce", nonce)
	req.Header.Set("Digest", digest)
	req.Header.Set("Authorization", `Ed25519 signature="`+signature+`"`)
	req.Header.Set("Content-Type", "application/json")
	return payload, nil
}

type Client struct {
	origin string
	keyID  string
	key    ed25519.PrivateKey
	http   *http.Client
}

func NewClient(origin, keyID string, privateKeyPEM []byte) (*Client, error) {
	base, err := url.Parse(origin)
	if err != nil || base.Scheme != "https" || base.Host == "" || base.User != nil ||
		base.RawQuery != "" || base.Fragment != "" || (base.Path != "" && base.Path != "/") {
		return nil, fmt.Errorf("expected an HTTPS API origin")
	}
	if len(keyID) != 32 {
		return nil, fmt.Errorf("expected a 32-character Base58 Key ID")
	}
	for _, c := range keyID {
		if !strings.ContainsRune("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", c) {
			return nil, fmt.Errorf("expected a Base58 Key ID")
		}
	}
	key, err := edLoadPrivateKey(privateKeyPEM)
	if err != nil {
		return nil, err
	}
	return &Client{origin: strings.TrimRight(origin, "/"), keyID: keyID, key: key,
		http: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error {
			return http.ErrUseLastResponse
		}}}, nil
}

// Do sends the exact body bytes. Caller closes resp.Body and checks HTTP and business status.
func (c *Client) Do(ctx context.Context, method, target string, body []byte) (*http.Response, error) {
	if !strings.HasPrefix(target, "/v2/") || strings.Contains(target, "#") {
		return nil, fmt.Errorf("expected an escaped /v2/ path and raw query")
	}
	for _, ch := range target {
		if ch <= 32 || ch >= 127 {
			return nil, fmt.Errorf("target must be escaped ASCII")
		}
	}
	req, err := http.NewRequestWithContext(ctx, strings.ToUpper(method), c.origin+target, bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	if req.URL.RequestURI() != target {
		return nil, fmt.Errorf("request target changed")
	}
	if _, err = edSignRequest(req, body, c.keyID, c.key, time.Now()); err != nil {
		return nil, err
	}
	return c.http.Do(req)
}

Python backend integration

Python 3.10+. Add cryptography to your application dependencies.

Call from your business service:

import json
from client import InfiniClient

# At startup: private_key_pem is bytes from your secret store.
client = InfiniClient(api_origin, key_id, private_key_pem)

# In your order service: order contains the persisted business request_id.
body = json.dumps(order, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
status, response = client.request("POST", "/v2/acquiring/order", body)
if not 200 <= status < 300:
    raise RuntimeError(f"Infini HTTP error: {status}")
result = json.loads(response)
if result["code"] != 0:
    raise RuntimeError(f"Infini business error: {result['code']}")
# Persist the result in your business system.

Complete client source

import base64
import hashlib
import http.client
import re
import secrets
import time
from urllib.parse import urlsplit

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey


def load_key(pem):
    pem = pem.strip()
    if not re.fullmatch(rb"-----BEGIN PRIVATE KEY-----[A-Za-z0-9+/=\r\n]+-----END PRIVATE KEY-----", pem):
        raise ValueError("Expected one unencrypted PKCS#8 PEM private key")
    key = serialization.load_pem_private_key(pem, password=None)
    if not isinstance(key, Ed25519PrivateKey):
        raise ValueError("Private key must be Ed25519")
    return key


def sign(key, key_id, method, target, body, timestamp, nonce):
    digest = "SHA-256=" + base64.b64encode(hashlib.sha256(body).digest()).decode("ascii")
    payload = "\n".join([
        "INFINI-OPENAPI-V2", key_id, method, target, timestamp, nonce, digest
    ]) + "\n"
    signature = base64.b64encode(key.sign(payload.encode("utf-8"))).decode("ascii")
    return {
        "X-API-Key-ID": key_id, "X-API-Timestamp": timestamp,
        "X-API-Nonce": nonce, "Digest": digest,
        "Authorization": f'Ed25519 signature="{signature}"',
        "Content-Type": "application/json",
    }, payload


class InfiniClient:
    def __init__(self, origin, key_id, private_key_pem):
        base = urlsplit(origin)
        if (base.scheme != "https" or not base.hostname or base.username
                or base.password or base.path not in ("", "/") or base.query or base.fragment):
            raise ValueError("Expected an HTTPS API origin")
        if not re.fullmatch(r"[1-9A-HJ-NP-Za-km-z]{32}", key_id):
            raise ValueError("Expected a 32-character Base58 Key ID")
        self.host, self.port = base.hostname, base.port
        self.key_id, self.key = key_id, load_key(private_key_pem)

    def request(self, method, target, body=b""):
        """Return (HTTP status, raw response bytes). Caller checks business code."""
        method = method.upper()
        if not re.fullmatch(r"[A-Z]+", method):
            raise ValueError("Invalid HTTP method")
        if (not target.startswith("/v2/") or "#" in target
                or any(ord(c) <= 32 or ord(c) >= 127 for c in target)):
            raise ValueError("Expected an escaped /v2/ path and raw query")
        nonce = base64.urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b"=").decode("ascii")
        headers, _ = sign(self.key, self.key_id, method, target, body,
                          str(int(time.time())), nonce)
        connection = http.client.HTTPSConnection(self.host, self.port, timeout=15)
        try:
            connection.request(method, target, body=body, headers=headers)
            response = connection.getresponse()
            return response.status, response.read()
        finally:
            connection.close()

Java backend integration

Java 17+. The client uses only the standard library. Use your existing JSON library for orders and responses.

Call from your business service:

// At startup: privateKeyPEM is the complete PEM text from your secret store.
InfiniClient client = new InfiniClient(apiOrigin, keyId, privateKeyPEM);

// In your order service: serialize the order once with your existing JSON library.
// orderJson contains the persisted business request_id.
byte[] body = orderJson.getBytes(java.nio.charset.StandardCharsets.UTF_8);
var response = client.request("POST", "/v2/acquiring/order", body);
if (response.statusCode() < 200 || response.statusCode() >= 300) {
    throw new IllegalStateException("Infini HTTP error: " + response.statusCode());
}
// Parse response.body() with your application's JSON library.
// Require business code == 0 before persisting a successful result.

Complete client source

// Java 17+; standard library only. Copy into your backend service.
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.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;

public class InfiniClient {
    static PrivateKey loadKey(String pem) throws Exception {
        pem = pem.trim();
        if (!pem.matches("-----BEGIN PRIVATE KEY-----[A-Za-z0-9+/=\\r\\n]+-----END PRIVATE KEY-----")) {
            throw new IllegalArgumentException("Expected one unencrypted PKCS#8 PEM private key");
        }
        String encoded = pem.replace("-----BEGIN PRIVATE KEY-----", "")
            .replace("-----END PRIVATE KEY-----", "").replaceAll("\\s", "");
        return KeyFactory.getInstance("Ed25519").generatePrivate(
            new PKCS8EncodedKeySpec(Base64.getDecoder().decode(encoded)));
    }

    static Map<String, String> sign(PrivateKey key, String keyId, String method,
            String target, byte[] body, String timestamp, String nonce) throws Exception {
        String digest = "SHA-256=" + Base64.getEncoder().encodeToString(
            MessageDigest.getInstance("SHA-256").digest(body));
        String payload = String.join("\n", "INFINI-OPENAPI-V2", keyId, method,
            target, timestamp, nonce, digest) + "\n";
        Signature signer = Signature.getInstance("Ed25519");
        signer.initSign(key);
        signer.update(payload.getBytes(StandardCharsets.UTF_8));
        Map<String, String> headers = new LinkedHashMap<>();
        headers.put("X-API-Key-ID", keyId);
        headers.put("X-API-Timestamp", timestamp);
        headers.put("X-API-Nonce", nonce);
        headers.put("Digest", digest);
        headers.put("Authorization", "Ed25519 signature=\""
            + Base64.getEncoder().encodeToString(signer.sign()) + "\"");
        headers.put("Content-Type", "application/json");
        return headers;
    }

    private final URI origin;
    private final String keyId;
    private final PrivateKey key;
    private final HttpClient http;

    public InfiniClient(String origin, String keyId, String privateKeyPEM) throws Exception {
        this.origin = URI.create(origin.replaceAll("/+$", ""));
        if (!"https".equals(this.origin.getScheme()) || this.origin.getHost() == null
                || this.origin.getRawUserInfo() != null || this.origin.getRawQuery() != null
                || this.origin.getRawFragment() != null || !this.origin.getRawPath().isEmpty()) {
            throw new IllegalArgumentException("Expected an HTTPS API origin");
        }
        if (!keyId.matches("[1-9A-HJ-NP-Za-km-z]{32}")) {
            throw new IllegalArgumentException("Expected a 32-character Base58 Key ID");
        }
        this.keyId = keyId;
        this.key = loadKey(privateKeyPEM);
        this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15))
            .followRedirects(HttpClient.Redirect.NEVER).build();
    }

    // Caller checks both HTTP status and the response business code.
    public HttpResponse<String> request(String method, String target, byte[] body) throws Exception {
        method = method.toUpperCase(java.util.Locale.ROOT);
        if (!method.matches("[A-Z]+") || !target.startsWith("/v2/")
                || !target.matches("[!-~]+") || target.contains("#")) {
            throw new IllegalArgumentException("Invalid method or escaped /v2/ request target");
        }
        URI uri = URI.create(origin.toString() + target);
        String requestTarget = uri.getRawPath()
            + (uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery());
        if (!requestTarget.equals(target)) throw new IllegalArgumentException("Target changed");
        byte[] random = new byte[16];
        new SecureRandom().nextBytes(random);
        String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
        Map<String, String> headers = sign(key, keyId, method, requestTarget, body,
            Long.toString(Instant.now().getEpochSecond()), nonce);
        HttpRequest.Builder request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(15))
            .method(method, HttpRequest.BodyPublishers.ofByteArray(body));
        headers.forEach(request::header);
        return http.send(request.build(), HttpResponse.BodyHandlers.ofString());
    }
}

For GET queries, call the same method with the complete escaped path and raw query and an empty byte body. Do not re-encode the target or re-serialize the body after signing. These clients do not automatically retry or follow redirects. A timeout does not establish business failure: resolve unknown outcomes using the endpoint's query and idempotency rules. When retrying the same business operation, retain its original request_id and call the client again to generate fresh authentication headers.

Errors and migration

  • HTTP 401: credential unavailable/disabled, invalid signature, timestamp, nonce or digest, or other authentication failure.
  • HTTP 403: authenticated credential lacks permission or the client IP is not allowed.
  • Business errors may still use HTTP 200 with a non-zero code; check both HTTP status and the response envelope.

Existing HMAC v1 credentials are deprecated. During the configured migration window, they support only creating an acquiring order and querying a single order or order list on /v1/acquiring. Other merchant operations require Ed25519 v2. Obtain a new credential and update the signing algorithm; changing only the URL or Key ID is insufficient. Follow the migration deadline communicated for your environment.

The acquiring v2 API does not provide /order/token/reissue or /token/reissue. For ordinary orders, the initially issued checkout token expires at the order expiry plus 10 minutes; token validity does not extend the order's payable lifetime. Webhook signatures and hosted-checkout JWT authentication are separate protocols and are unchanged by this migration.