For the complete documentation index, see llms.txt. This page is also available as Markdown.
Signature and verify
Signing and verification rules for all API and webhook interactions.
This document defines the signing and verification rules for all parties interacting with the UR ecosystem. It serves as the single source of truth for signature formats and authentication mechanisms.
Overview
The UR ecosystem uses two primary authentication methods depending on the API being accessed:
Partner Authentication (Server-to-Server): Used for UR-OPEN-API and Webhooks. Authenticates the Partner backend using a registered ECDSA key pair.
User Authentication (Wallet-to-Server): Used for UR-API. Authenticates individual end-users using their wallet's private key.
Both methods utilize Ethereum Personal Sign (EIP-191): "\x19Ethereum Signed Message:\n{len(messageToSign)}{messageToSign}"
Part A: Partner authentication (UR-OPEN-API & webhooks)
This method is used when a Partner backend calls UR APIs or when UR sends Webhooks to a Partner.
1. HTTP headers
Header
Description
Required
X-Api-Signature
Hexadecimal signature with 0x prefix.
Yes
X-Api-Deadline
The Unix timestamp (in seconds) indicating when the request expires. Setting this to 5 minutes from the current time.
requestBody: The exact raw JSON string in the request body.
deadline: The value sent in the X-Api-Deadline header, a timestamp within the next 5 minutes.
Message to Sign: Standard OpenAPI requests use messageToSign = "{requestBody} {deadline}" (note the single space between body and deadline). User-scoped FMA OpenAPI requests use messageToSign = "{canonicalPayload}urId:{X-Ur-Id}externalUserId:{X-External-User-Id} {deadline}"; see API reference: Managed Custody Mode.
Bodyless requests (GET / DELETE / HEAD): there is no request body, so the raw query string takes the place of requestBody. The message becomes messageToSign = "{rawQueryString} {deadline}" (an empty message, meaning no query string, is rejected).
Construct Final Message: finalMessage = "I agree to access my profile. " + intermediateHash.hex().
Sign: The user signs the finalMessage using their wallet (EIP-191).
Key pair
If you use the API sandbox, it generates and registers this key pair for you. The steps below are for generating your own key pair instead. See API signing key for both paths.
1. Generate key pair using Node.js (viem)
2. Key management recommendations
Private Key Storage: Use key management services like AWS Secrets Manager, HashiCorp Vault Public Key Registration: Synchronize the generated address (publicKey) with the UR system through secure channels
Code examples
signature
verify
User wallet signature
Important notes
Raw Body: Always use the raw HTTP body bytes. Do not re-serialize JSON, as key ordering or whitespace differences will cause signature mismatches.
Deadline Window: Recommended window is 1-5 minutes. Requests with an expired deadline are rejected. A deadline too far in the future is also rejected, but the maximum forward window is server-configured (typically 5 minutes) and is only enforced when that limit is set; it is not a hard-coded 5-minute constant.
Replay Protection: For high-concurrency environments, add a small random offset (1-5 seconds) to the deadline to ensure each request generates a unique signature.
Case Sensitivity: Ethereum addresses should be treated as case-insensitive but are typically stored/transmitted in lowercase or checksum format.
import (
"fmt"
"time"
"strconv"
"encoding/hex"
"github.com/ethereum/go-ethereum/crypto"
)
// GenSignature generates API request signature
// Parameters:
// privateKeyHex: private key hex string (without 0x prefix)
// msg: business message content (typically the request body JSON string)
// deadline: signature expiration time (Unix timestamp, seconds)
func GenSignature(privateKeyHex string, msg string, deadline int64) (string, error) {
// 1. Build deadline string
deadlineStr := strconv.FormatInt(deadline, 10)
// For concurrent requests within the same second, you can add a very small random offset to the deadline (but still within the allowed window) to ensure signature uniqueness
messageToSign := fmt.Sprintf("%s %s", msg, deadlineStr)
// 2. Add Ethereum personal signature prefix
prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(messageToSign))
prefixedMessageBytes := []byte(prefix + messageToSign)
// 3. Calculate message hash
messageHash := crypto.Keccak256Hash(prefixedMessageBytes)
// 4. Load private key and perform signature
privateKey, err := crypto.HexToECDSA(privateKeyHex)
if err != nil {
return "", fmt.Errorf("failed to load private key: %w", err)
}
signatureBytes, err := crypto.Sign(messageHash.Bytes(), privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign message: %w", err)
}
// 5. Return hex-encoded signature (including recovery ID)
return "0x" + hex.EncodeToString(signatureBytes), nil
}
// recoverAddress recovers signer's public key address from signature and original message
func recoverAddress(message, sigHex string) (common.Address, error) {
// Decode hex signature
sigBytes, err := hex.DecodeString(strings.TrimPrefix(sigHex, "0x"))
if err != nil {
return common.Address{}, fmt.Errorf("invalid hex signature: %w", err)
}
if len(sigBytes) != 65 {
return common.Address{}, fmt.Errorf("signature length must be 65 bytes, got %d", len(sigBytes))
}
// Compatible handling of recovery ID (v): some libraries return 27/28, need to convert to 0/1
if sigBytes[64] >= 27 {
sigBytes[64] -= 27
}
// Rebuild prefixed message hash
prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message))
prefixedMsgBytes := []byte(prefix + message)
hash := crypto.Keccak256Hash(prefixedMsgBytes)
// Recover public key from signature
pubKey, err := crypto.SigToPub(hash.Bytes(), sigBytes)
if err != nil {
return common.Address{}, fmt.Errorf("could not recover public key: %w", err)
}
return crypto.PubkeyToAddress(*pubKey), nil
}
// We only allow the signature to be valid for 20 minutes max.
// Can be less than that if want more security.
const SIGNATURE_DEADLINE_IN_SECONDS = 1200;
// Alternative to Date.now()
// sometimes the device clock is out of sync and can give the wrong timestamp
const serverTimestamp = await fetch("https://api.fiat24.com/timestamp");
const now = serverTimestamp.timestamp;
const deadline = Math.round(now / 1000) + SIGNATURE_DEADLINE_IN_SECONDS;
const hash = "Hello world"; // Could be any text or payload hash
// Calculate Hash of (hash + deadline)
// Example using web3.js
const deadlineHash = web3.utils.sha3(hash + deadline);
// Example using ethers.js
// const deadlineHash = ethers.keccak256(ethers.toUtf8Bytes(hash + deadline));
// Construct the specific message required by UR
const messageToSign = `I agree to access my profile. ${deadlineHash}`;
// Generate Signature
// Example using web3.js
const sign = await web3.eth.personal.sign(messageToSign, address);
// Example using ethers.js
// const sign = await signer.signMessage(messageToSign);
return { hash, deadline, sign };