Action Confirmation Signatures
Exchange a wallet signature for an action token, with EOA, smart-contract wallet and Stellar signers.
Before You Start
Read the following guides before proceeding:
| Guide | Why |
|---|---|
| Authentication | The corporation token whose employee_address must sign |
| Card Details | The full sign → exchange → read flow that consumes the action token |
| 3DS Authentication | The other consumer of action tokens |
Overview
The action confirmation endpoint exchanges a message signed by the employee's wallet for an action token.
The signer can be an EVM EOA, an EVM smart-contract wallet (ERC-1271)
or a Stellar account.
| Method | Path | Permissions |
|---|---|---|
POST | /api/v1/confirmation/signature/verify | CardSu or CardViewDetails |
POST | /api/v1/confirmation/signature (obsolete — use /verify) | CardSu or CardViewDetails |
Both accept the corporation token only and behave identically.
Who Signs
The signer is always the address in the corporation token's employee_address claim — the
X-User-Wallet sent to POST /api/v1/corporations/login.
A signature from any other wallet is rejected, including a smart account controlled by that wallet.
If the employee logged in with an EOA that owns a smart account, sign with the EOA. ERC-1271 applies only
whenemployee_addressitself is the contract.
Wallet behind employee_address | Signature | message_signature encoding |
|---|---|---|
| EVM EOA | EIP-191 personal_sign over the message. v as 27/28 or 0/1 | 0x hex, 65 bytes |
| EVM smart-contract wallet | Any bytes the wallet's isValidSignature(hash, signature) accepts, where hash is the EIP-191 hash of the message | 0x hex. Passed to the contract exactly as sent |
| Stellar | Ed25519 over the SHA-256 of the message | Standard base64 |
| XRPL | Not accepted on these endpoints | — |
The message, character for character:
By signing this I confirm that I am executing action <action_type> at <nonce>
Smart-Contract Wallet Conditions
- The contract must be deployed on the chain stored in the corporation token's
chain_idclaim — the
X-Chain-Idsent at login, or the default chain when none was sent. An undeployed (counterfactual) account
cannot pass. - The
isValidSignaturecall has a 3-second timeout. A timeout, a revert, an unreachable node or any return
value other than0x1626ba7erejects the signature asinvalid_signature. - The ERC-1271 check runs only after EOA recovery fails, so an EOA signature never depends on a node call.
Request
POST /api/v1/confirmation/signature/verify
{
"action_type": "GetCardDetails",
"message_signature": "0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac81f0c5b0e4a7c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c1b",
"nonce": 1761480000
}Signed message for this request:
By signing this I confirm that I am executing action GetCardDetails at 1761480000
| Field | Type | Required | Description |
|---|---|---|---|
action_type | string | Yes | GetCardDetails or 3dsChallenge |
message_signature | string | Yes | Signature over the message, encoded per Who Signs |
nonce | integer (int64) | Yes | Unix time in seconds used in the message. Rejected when more than 120 seconds old |
Response
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}| Field | Description |
|---|---|
token | Action token for the action_type that was signed |
Error Handling
{
"error_reason": "ErrorInvalidField",
"error_description": "Invalid signature",
"error_category": {
"category": "CategoryValidationFailure",
"http_status_code": 400
},
"error_details": [
{ "key": "field", "details": "signature" },
{ "key": "issue", "details": "invalid_signature" },
{ "key": "signature", "details": "Invalid signature" }
]
}Validation Errors (400)
| Error Reason | field / issue | error_description | Returned when |
|---|---|---|---|
ErrorMissingField | action_type / missing | action type is required | action_type is empty |
ErrorInvalidField | action_type / invalid_value | Invalid value for action type | action_type is not GetCardDetails or 3dsChallenge |
ErrorMissingField | message_signature / missing | message signature is required | message_signature is empty |
ErrorMissingField | nonce / missing | nonce is required | nonce is missing or 0 |
ErrorInvalidField | nonce / expired | Invalid nonce | nonce is more than 120 seconds old |
ErrorInvalidField | signature / invalid_format | Invalid format for signature | EVM signature is not hex, or is shorter than 65 bytes and the wallet does not accept it over ERC-1271. Stellar signature is not base64 |
ErrorInvalidField | signature / invalid_signature | Invalid signature | The signature is not employee_address's signature over this exact message: another wallet, another action_type or nonce, a smart-contract wallet that declines it, or a smart-contract wallet that cannot be reached on its chain |
Server Errors (500)
| Error Reason | Description | Returned when |
|---|---|---|
ErrorGeneral | Unsupported chain type | employee_address is an XRPL address |
ErrorGeneral | Failed to issue action token | The signature is valid, but the action token could not be issued |
Reading a Signature Error
const res = await fetch(`${baseUrl}/api/v1/confirmation/signature/verify`, {
method: 'POST',
headers: { Authorization: `Bearer ${corporationToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ action_type: 'GetCardDetails', message_signature: signature, nonce })
});
if (res.status === 400) {
const err = await res.json();
const details = Object.fromEntries((err.error_details ?? []).map(d => [d.key, d.details]));
if (err.error_reason === 'ErrorInvalidField' && details.field === 'signature') {
if (details.issue === 'invalid_signature') {
// Ask the user to sign again with the employee_address wallet, with a fresh nonce
} else if (details.issue === 'invalid_format') {
// Encoding defect in the integration
}
}
}res = requests.post(
f"{base_url}/api/v1/confirmation/signature/verify",
headers={"Authorization": f"Bearer {corporation_token}"},
json={"action_type": "GetCardDetails", "message_signature": signature, "nonce": nonce},
)
if res.status_code == 400:
err = res.json()
details = {d["key"]: d["details"] for d in err.get("error_details", [])}
if err["error_reason"] == "ErrorInvalidField" and details.get("field") == "signature":
if details.get("issue") == "invalid_signature":
pass # Ask the user to sign again with the employee_address wallet, with a fresh nonce
elif details.get("issue") == "invalid_format":
pass # Encoding defect in the integrationbody, _ := json.Marshal(map[string]any{
"action_type": "GetCardDetails",
"message_signature": signature,
"nonce": nonce,
})
req, _ := http.NewRequest("POST", baseURL+"/api/v1/confirmation/signature/verify", bytes.NewBuffer(body))
req.Header.Set("Authorization", "Bearer "+corporationToken)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
if res.StatusCode == http.StatusBadRequest {
var apiErr struct {
ErrorReason string `json:"error_reason"`
ErrorDetails []struct {
Key string `json:"key"`
Details string `json:"details"`
} `json:"error_details"`
}
json.NewDecoder(res.Body).Decode(&apiErr)
details := map[string]string{}
for _, d := range apiErr.ErrorDetails {
details[d.Key] = d.Details
}
if apiErr.ErrorReason == "ErrorInvalidField" && details["field"] == "signature" {
switch details["issue"] {
case "invalid_signature":
// Ask the user to sign again with the employee_address wallet, with a fresh nonce
case "invalid_format":
// Encoding defect in the integration
}
}
}Updated about 3 hours ago

