API authentication
Every Plugipay API request must be signed. We use HMAC-SHA256 request signing with a key ID + key secret pair you mint in the dashboard.
This page covers the exact recipe with a worked example. If you're using one of our SDKs (Node, Python, Go) or the CLI, signing is automatic — you only need this page if you're integrating directly over HTTP.
TL;DR
For every request:
- Compute
bodyHash = sha256(request body in bytes)— empty string forGET/DELETE. - Build a string-to-sign:
METHOD\npath\ntimestamp\nbodyHash[\nidempotencyKey] signature = HMAC-SHA256(secret, stringToSign)— hex-encoded.- Send two headers:
Authorization: Plugipay-HMAC-SHA256 keyId=<id>, scope=*, signature=<hex>X-Plugipay-Timestamp: <epoch seconds>
The key pair
Generate an API key in Settings → API keys (see Portal → API keys). You'll get two values:
| Field | Format | Visibility |
|---|---|---|
| Access key ID | pk_test_xxxxxxxxxxxxxxxx or pk_live_xxxxxxxxxxxxxxxx |
Public (safe to log) |
| Secret | sk_test_xxxxxxxxxxxxxxxxxxxxxxxx or sk_live_xxxxxxxxxxxxxxxxxxxxxxxx |
Secret — shown once |
The _test_ and _live_ prefixes encode the environment. They share the same base URL (https://api.plugipay.com); the key alone determines whether you're hitting test mode or live mode.
The secret appears only once. When you create a key, Plugipay shows the secret in a dialog. If you close it without copying, you have to mint a new key. There's no recovery flow.
The signing recipe
1. Compute the body hash
Hash the exact bytes of the request body you're going to send, using SHA-256:
bodyHash = hex(sha256(body))
For GET and DELETE (or any request without a body), use the empty string:
bodyHash = hex(sha256("")) = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
For POST/PUT/PATCH with a JSON body, hash the serialized JSON — the same bytes you put on the wire. Whitespace matters here: if you JSON.stringify with no indentation and send it, hash the no-indentation form; if you pretty-print, hash the pretty-printed form. The server hashes whatever it receives.
Most JSON serializers use a canonical compact form by default. Node's
JSON.stringify, Python'sjson.dumps, and Go'sjson.Marshalall produce no-whitespace output. Use these defaults and you don't need to think about it.
2. Build the string-to-sign
Five (or four) fields joined by literal \n (newline):
METHOD\n
path\n
timestamp\n
bodyHash\n
idempotencyKey (only if you're sending the Idempotency-Key header)
| Field | Example |
|---|---|
METHOD |
POST (uppercase) |
path |
/v1/customers — include the query string if any, e.g. /v1/customers?limit=10 |
timestamp |
1715526783 (current epoch seconds; must be within 300 seconds of server time) |
bodyHash |
hex SHA-256 of the body |
idempotencyKey |
the exact value of the Idempotency-Key header, if present |
So a POST /v1/customers with an idempotency key looks like:
POST
/v1/customers
1715526783
b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78
order-2026-05-12-001
A GET /v1/customers?limit=10 (no body, no idempotency key):
GET
/v1/customers?limit=10
1715526783
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
3. Compute the signature
signature = hex(HMAC-SHA256(secret, stringToSign))
Use the secret (sk_..._...), not the access key ID, as the HMAC key.
4. Build the headers
Two headers go on every request:
Authorization: Plugipay-HMAC-SHA256 keyId=pk_test_xxxxxxxxxxxxxxxx, scope=*, signature=<hex>
X-Plugipay-Timestamp: 1715526783
The scope=* field is for future use (per-key permission scoping). For now, always use scope=*.
If your request has a body, also send:
Content-Type: application/json
If you're sending an idempotency key, add it (the value here must match what's in the string-to-sign):
Idempotency-Key: order-2026-05-12-001
If you're a platform admin acting on behalf of a merchant workspace:
X-Plugipay-On-Behalf-Of: acc_01HXXxxxxxxxxxxxxxxxxxxxxxx
Worked example
Sign a POST /v1/customers with these inputs:
- Access key ID:
pk_test_AKIAxxxxxxxxxx - Secret:
sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx - Timestamp:
1715526783 - Body:
{"email":"alice@example.com","name":"Alice"}
Step 1: body hash
sha256('{"email":"alice@example.com","name":"Alice"}')
= "8d2c5b3b3..."
Step 2: string-to-sign
POST
/v1/customers
1715526783
8d2c5b3b3...
Step 3: signature (using the secret as the HMAC key)
HMAC-SHA256("sk_test_xxx...", stringToSign)
= "7c4f1a2d3b4c5d6e7f8a9b0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6"
Step 4: send
POST /v1/customers HTTP/1.1
Host: api.plugipay.com
Authorization: Plugipay-HMAC-SHA256 keyId=pk_test_AKIAxxxxxxxxxx, scope=*, signature=7c4f1a2d3b4c5d6e7f8a9b0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6
X-Plugipay-Timestamp: 1715526783
Content-Type: application/json
{"email":"alice@example.com","name":"Alice"}
A complete curl example
For copy-paste, here's a shell function that signs and sends a Plugipay request:
plugipay_curl() {
local METHOD="$1"
local PATH_QS="$2"
local BODY="${3:-}"
local TS=$(date +%s)
local BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $2}')
local STRING_TO_SIGN="${METHOD}
${PATH_QS}
${TS}
${BODY_HASH}"
local SIG=$(printf '%s' "$STRING_TO_SIGN" | \
openssl dgst -sha256 -hmac "$PLUGIPAY_KEY_SECRET" | \
awk '{print $2}')
curl -sS -X "$METHOD" "https://api.plugipay.com$PATH_QS" \
-H "Authorization: Plugipay-HMAC-SHA256 keyId=$PLUGIPAY_KEY_ID, scope=*, signature=$SIG" \
-H "X-Plugipay-Timestamp: $TS" \
${BODY:+-H "Content-Type: application/json"} \
${BODY:+-d "$BODY"}
}
# Usage:
export PLUGIPAY_KEY_ID=pk_test_xxx
export PLUGIPAY_KEY_SECRET=sk_test_xxx
plugipay_curl GET '/v1/customers?limit=5'
plugipay_curl POST '/v1/customers' '{"email":"alice@example.com","name":"Alice"}'
For more complex flows or production code, use one of our SDKs — they handle this automatically.
Reference: signing in each SDK language
For comparison with your own implementation, here's what the SDKs do:
Node.js:
const crypto = require('node:crypto');
function sign(secret, method, path, timestamp, body, idempotencyKey) {
const bodyHash = crypto.createHash('sha256').update(body || '').digest('hex');
const parts = [method.toUpperCase(), path, timestamp, bodyHash];
if (idempotencyKey) parts.push(idempotencyKey);
const stringToSign = parts.join('\n');
return crypto.createHmac('sha256', secret).update(stringToSign).digest('hex');
}
Python:
import hashlib, hmac
def sign(secret, method, path, timestamp, body, idempotency_key=None):
body_hash = hashlib.sha256((body or '').encode()).hexdigest()
parts = [method.upper(), path, str(timestamp), body_hash]
if idempotency_key:
parts.append(idempotency_key)
string_to_sign = '\n'.join(parts)
return hmac.new(secret.encode(), string_to_sign.encode(), hashlib.sha256).hexdigest()
Go:
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func sign(secret, method, path, timestamp, body, idempotencyKey string) string {
h := sha256.New()
h.Write([]byte(body))
bodyHash := hex.EncodeToString(h.Sum(nil))
parts := []string{strings.ToUpper(method), path, timestamp, bodyHash}
if idempotencyKey != "" {
parts = append(parts, idempotencyKey)
}
stringToSign := strings.Join(parts, "\n")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stringToSign))
return hex.EncodeToString(mac.Sum(nil))
}
Timestamp tolerance
The server rejects requests where X-Plugipay-Timestamp is more than 300 seconds (5 minutes) off server time. This blocks replay attacks: a captured signature is useless 5 minutes later.
If you see 401 invalid_timestamp or 401 timestamp_skew:
- Make sure your system clock is correct. Run
ntpdate -q pool.ntp.orgon Linux or check System Settings → Date & Time on macOS. - If you're in CI, ensure the runner's clock is in sync (some Docker hosts drift).
Common errors
401 invalid_signature
The signature didn't match what the server computed. Causes (in order of likelihood):
- Wrong secret — you copied the access key ID as the secret, or partial copy.
- Wrong string-to-sign format — extra whitespace, wrong field order, missing newline before idempotency key, sending an idempotency key in the header but not in the signature (or vice versa).
- Body bytes don't match — you hashed pretty-printed JSON but sent compact, or vice versa.
- Path with vs without query string — we sign the path including the query string.
/v1/customers?limit=10and/v1/customersproduce different signatures.
To debug, log the exact string-to-sign and body bytes on your side. The server doesn't echo them back (intentional — would leak request shape).
401 invalid_key
The access key ID doesn't exist, has been revoked, or is from a different workspace. Verify the ID matches what's in the portal under Settings → API keys.
401 invalid_timestamp
Your timestamp is more than 5 minutes off. Sync the clock.
401 mode_mismatch
You used a pk_test_* key to hit a live-mode-only path, or vice versa. Test keys can't process real money; live keys can't be used in test mode.
403 insufficient_scope
The key exists but doesn't have the scope to perform this operation. Check the key's role in the portal.
Webhook signatures vs API signatures
This page covers outbound API requests (your code → Plugipay). Plugipay also signs inbound webhooks (Plugipay → your endpoint) with a different scheme — see Webhooks for that signature recipe.
Next
- Conventions — envelope, fields, types.
- Idempotency — how to safely retry.
- Errors — the error code catalog.
- SDKs — if you'd rather not implement signing yourself.