Encrypting and decrypting payloads
Every funding request and response is an encrypted JWE. The plaintext inside is a JWT carrying the business claims.
The wire format is a compact-serialized JWE (RFC 7516 §7.1): five
base64url parts separated by dots, as
protected_header.encrypted_key.iv.ciphertext.tag.
The parameters #
Both directions use the same cryptography:
| Parameter | Value |
|---|---|
Key management (alg) | A256KW (AES-256 key wrap) |
Content encryption (enc) | A256GCM |
Header typ / cty | JWT / JWT (the content is a nested JWT) |
| Wrapping key | The raw 32-byte signing secret from setup |
The inner JWT has standard iat and exp claims (Unix seconds) at the
top level. All Moov fields live under a single moov object, so they
cannot collide with registered JWT claims (sub, aud, and the rest):
{
"iat": 1710000000,
"exp": 1710000300,
"moov": { "idempotencyKey": "…", "payoutID": "…", "amount": { } }
}
Field-by-field in the API reference
(authorize,
credit account).
Moov Money issues requests with
exp = iat + 5 minutes; reject requests where exp has passed or iat is
implausibly far in the future, allowing a small clock-skew leeway.
Decrypt a request #
Your endpoint receives { "request": "<JWE>" }. Unwrap it with your
32-byte secret:
Using the jose package, jwtDecrypt
decrypts the JWE and validates iat/exp in one call:
import { jwtDecrypt, base64url } from 'jose';
// The secret exactly as returned by POST /providers/{id}/signing-secrets
const key = base64url.decode(process.env.MOOV_SIGNING_SECRET);
export async function readRequest(body) {
const { payload } = await jwtDecrypt(body.request, key, {
contentEncryptionAlgorithms: ['A256GCM'],
keyManagementAlgorithms: ['A256KW'],
clockTolerance: '30s',
});
// payload.moov.idempotencyKey, payload.moov.payoutID, payload.moov.amount
return payload;
}
Using go-jose, decrypt the JWE and
unmarshal the inner JWT claims:
import (
"encoding/base64"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// The secret exactly as returned by POST /providers/{id}/signing-secrets
var key, _ = base64.RawURLEncoding.DecodeString(os.Getenv("MOOV_SIGNING_SECRET"))
type AuthorizeRequestClaims struct {
IdempotencyKey string `json:"idempotencyKey"`
PayoutID string `json:"payoutID"`
ProviderID string `json:"providerID"`
ExternalID string `json:"externalID"`
FundingSource string `json:"fundingSource"`
Amount Money `json:"amount"`
FraudScore float32 `json:"fraudScore"`
}
type AuthorizeRequestJWTPayload struct {
jwt.Claims
Moov AuthorizeRequestClaims `json:"moov"`
}
func readRequest(envelope RequestEnvelope) (*AuthorizeRequestJWTPayload, error) {
tok, err := jwt.ParseEncrypted(envelope.Request,
[]jose.KeyAlgorithm{jose.A256KW},
[]jose.ContentEncryption{jose.A256GCM})
if err != nil {
return nil, err
}
var payload AuthorizeRequestJWTPayload
if err := tok.Claims(key, &payload); err != nil {
return nil, err
}
if err := payload.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
return nil, err
}
return &payload, nil
}
Encrypt a response #
Build the moov object, always echoing the request’s idempotencyKey,
and encrypt it with the same key. iat and exp stay at the top of
the JWT, not inside moov:
import { EncryptJWT, base64url } from 'jose';
const key = base64url.decode(process.env.MOOV_SIGNING_SECRET);
export async function writeResponse(moov) {
const jweString = await new EncryptJWT({ moov })
.setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM', typ: 'JWT', cty: 'JWT' })
.setIssuedAt()
.setExpirationTime('5m')
.encrypt(key);
return { response: jweString };
}
// e.g. approving an authorize request:
await writeResponse({
idempotencyKey: request.moov.idempotencyKey, // echoed; Moov rejects a mismatch
outcome: 'approved',
holdReference: 'hold_9f8e7d6c5b4a',
});
func writeResponse(moov any) (*ResponseEnvelope, error) {
enc, err := jose.NewEncrypter(
jose.A256GCM,
jose.Recipient{Algorithm: jose.A256KW, Key: key},
(&jose.EncrypterOptions{}).WithType("JWT").WithContentType("JWT"),
)
if err != nil {
return nil, err
}
now := time.Now()
payload := struct {
jwt.Claims
Moov any `json:"moov"`
}{
Claims: jwt.Claims{
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
},
Moov: moov,
}
jweString, err := jwt.Encrypted(enc).Claims(payload).Serialize()
if err != nil {
return nil, err
}
return &ResponseEnvelope{Response: jweString}, nil
}
Verifying your implementation #
Round-trip your own output before pointing Moov Money at it: encrypt a
response, decrypt it with the same key, and confirm the claims survive
intact. The most common integration failures are using the base64url
string as the key instead of the decoded 32 bytes, and omitting the
cty: JWT header parameter.
Next steps #
- Ledger endpoints: what claims arrive on each call and what you must return.