Before merchants can use your gateway, they must register and verify ownership of their OCID. This guide covers the secure onboarding process.
Overview
- Merchant provides their OCID
- You fetch their metadata from the blockchain registry
- Merchant signs a challenge to prove ownership
- You verify the signature and store the merchant
Step 1: Get Your OCID
Before onboarding merchants, you need your own OCID:
- Mint an OCID on the Router Registry smart contract
- Set your metadata URL pointing to your
/metadata.json
- Store your OCID and private key securely (e.g.,
.env file)
// .env
GATEWAY_OCID=300
GATEWAY_PRIVATE_KEY=your_private_key_hex
GATEWAY_PUBLIC_KEY=your_public_key_hex
Step 2: Collect Merchant OCID
Create a registration form where merchants provide their OCID:
app.post('/merchants/register', async (req, res) => {
const { ocid } = req.body;
// Validate OCID is a positive integer
if (!Number.isInteger(ocid) || ocid <= 0) {
return res.status(400).json({ error: 'Invalid OCID' });
}
// Generate a challenge for the merchant to sign
const challenge = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + 10 * 60 * 1000; // 10 minutes
await db.savePendingRegistration({
ocid,
challenge,
expiresAt
});
res.json({
challenge,
message: `Sign this challenge with your OCID ${ocid} private key`,
expiresAt
});
});
Always fetch metadata through an independent channel, never from a URL provided by the merchant directly. Use the Router Registry or Opencharge index.
async function fetchMerchantMetadata(ocid) {
// Option 1: Query blockchain directly
const metadataUrl = await routerRegistry.getMetadataUrl(ocid);
// Option 2: Use Opencharge index
const indexResponse = await fetch(`https://index.opencharge.network/ocid/${ocid}`);
const { metadataUrl } = await indexResponse.json();
// Fetch metadata
const response = await fetch(metadataUrl);
if (!response.ok) {
throw new Error('Metadata URL unreachable - merchant may be offline');
}
const metadata = await response.json();
// Verify config signature
const { config, signature } = metadata;
const canonical = JSON.stringify(config, Object.keys(config).sort());
if (!secp256k1_verify(config.publicKey, signature, SHA256(canonical))) {
throw new Error('Invalid metadata signature');
}
return metadata;
}
Step 4: Verify Ownership
The merchant signs your challenge to prove they control the private key:
app.post('/merchants/verify', async (req, res) => {
const { ocid, signature } = req.body;
// 1. Get pending registration
const pending = await db.getPendingRegistration(ocid);
if (!pending || pending.expiresAt < Date.now()) {
return res.status(400).json({ error: 'Challenge expired or not found' });
}
// 2. Fetch merchant metadata
let metadata;
try {
metadata = await fetchMerchantMetadata(ocid);
} catch (err) {
return res.status(400).json({ error: err.message });
}
// 3. Verify signature of challenge
const publicKey = metadata.config.publicKey;
if (!secp256k1_verify(publicKey, signature, SHA256(pending.challenge))) {
return res.status(400).json({ error: 'Invalid signature' });
}
// 4. Optional: Verify public key owns the OCID on-chain
const derivedAddress = publicKeyToAddress(publicKey);
const nftOwner = await routerRegistry.ownerOf(ocid);
if (derivedAddress.toLowerCase() !== nftOwner.toLowerCase()) {
console.warn(`Public key does not own OCID ${ocid} on-chain`);
// Decide whether to reject or just warn
}
// 5. Store merchant
await db.createMerchant({
ocid,
name: metadata.name,
publicKey,
endpoint: metadata.config.endpoint,
webhookUrl: `${metadata.config.endpoint}/transfer/webhook`,
capabilities: metadata.config.capabilities,
currencies: metadata.config.settlement?.currencies || ['USD'],
status: 'active',
createdAt: Date.now()
});
// 6. Clean up pending registration
await db.deletePendingRegistration(ocid);
res.json({
success: true,
merchant: {
ocid,
name: metadata.name
}
});
});
Security Considerations
If a merchant’s metadata URL is unreachable:
- Do not onboard them
- This may indicate the OCID is compromised
- Merchants can take their metadata offline as a safety measure
if (!response.ok) {
return res.status(400).json({
error: 'Merchant metadata URL is unreachable. Cannot verify identity.'
});
}
Signature Verification
Always verify:
- The metadata
config is signed by the publicKey in the config
- The challenge is signed by the same public key
- Optionally, that the public key owns the OCID NFT on-chain
Unique OCIDs
OCIDs are unique across the ecosystem. Store them under a unique index:
// Database schema
CREATE UNIQUE INDEX idx_merchants_ocid ON merchants(ocid);
Auth Middleware
After onboarding, authenticate all requests from merchants:
async function signatureAuth(req, res, next) {
const ocid = parseInt(req.headers['x-oc-id']);
const timestamp = req.headers['x-oc-timestamp'];
const nonce = req.headers['x-oc-nonce'];
const signature = req.headers['x-oc-signature'];
// 1. Get merchant
const merchant = await db.getMerchant(ocid);
if (!merchant) {
return res.status(401).json({
error: { code: 'MERCHANT_NOT_REGISTERED', message: 'Unknown merchant' }
});
}
// 2. Check timestamp (within 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return res.status(401).json({
error: { code: 'TIMESTAMP_EXPIRED', message: 'Request too old' }
});
}
// 3. Check nonce not reused
if (await db.isNonceUsed(ocid, nonce)) {
return res.status(401).json({
error: { code: 'NONCE_REUSED', message: 'Nonce already used' }
});
}
// 4. Verify signature
const bodyHash = SHA256(JSON.stringify(req.body) || '');
const canonical = [ocid, timestamp, nonce, req.method, req.path, bodyHash].join('\n');
if (!secp256k1_verify(merchant.publicKey, signature, SHA256(canonical))) {
return res.status(401).json({
error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed' }
});
}
// 5. Store nonce
await db.storeNonce(ocid, nonce);
req.merchant = merchant;
next();
}
// Use middleware on protected routes
app.post('/orders/checkout', signatureAuth, handleCheckout);
Next Steps