Skip to main content
POST
/
transfer
/
webhook
Instant Transfer Notification (ITN)
curl --request POST \
  --url https://api.merchant.example/opencharge/transfer/webhook \
  --header 'Content-Type: application/json' \
  --header 'X-OC-ID: <x-oc-id>' \
  --header 'X-OC-Nonce: <x-oc-nonce>' \
  --header 'X-OC-Signature: <x-oc-signature>' \
  --header 'X-OC-Timestamp: <x-oc-timestamp>' \
  --data '
{
  "proof": {
    "txid": "gateway_tx_456",
    "issuer": 100,
    "from": {
      "ocid": 200,
      "reference": "wallet_tx_123"
    },
    "to": {
      "ocid": 500,
      "reference": "ord_abc123"
    },
    "amount": "15.00",
    "currency": "USD",
    "timestamp": 1706500500,
    "memo": "Payment for ord_abc123"
  },
  "signature": "a1b2c3d4e5f6..."
}
'
{
  "status": "accepted",
  "txid": "gateway_tx_456"
}
This is the primary way you learn that a payment has been completed. When a transfer involving your OCID finishes, the payment gateway POSTs a signed proof to this endpoint.

Flow

  1. Customer pays via a payment gateway
  2. Gateway completes the transfer to your OCID
  3. Gateway POSTs signed proof to your /transfer/webhook
  4. You verify the proof and mark the order as paid

Verifying the Proof

Always verify the proof before trusting it:
app.post('/transfer/webhook', async (req, res) => {
  const { proof, signature } = req.body;

  // 1. Check the issuer is trusted
  if (!acceptedIssuers.includes(proof.issuer)) {
    return res.status(400).json({
      error: { code: 'ISSUER_NOT_ACCEPTED', message: 'Unknown issuer' }
    });
  }

  // 2. Fetch issuer's public key
  const issuerMetadata = await fetch(`${issuerEndpoint}/metadata.json`);
  const publicKey = issuerMetadata.config.publicKey;

  // 3. Verify signature
  const canonical = JSON.stringify(proof, Object.keys(proof).sort());
  if (!secp256k1_verify(publicKey, signature, SHA256(canonical))) {
    return res.status(400).json({
      error: { code: 'PROOF_SIGNATURE_INVALID', message: 'Invalid signature' }
    });
  }

  // 4. Check recipient is your OCID
  if (proof.to.ocid !== YOUR_OCID) {
    return res.status(400).json({
      error: { code: 'INVALID_PROOF', message: 'Wrong recipient' }
    });
  }

  // 5. Find and update the order
  const order = db.getOrderByReference(proof.to.reference);
  if (order && proof.amount === order.amount) {
    db.markOrderPaid(order.id, proof.txid, proof.timestamp);
  }

  res.json({ status: 'accepted', txid: proof.txid });
});

Proof Fields

FieldDescription
proof.txidUnique transaction ID from the issuer
proof.issuerOCID of the service that executed the transfer
proof.from.ocidSender’s OCID
proof.to.ocidYour merchant OCID
proof.to.referenceYour order ID (if provided during payment)
proof.amountTransfer amount
proof.currencyCurrency code
proof.timestampWhen the transfer completed

Respond to the webhook call

Return accepted if you processed the notification:
{
  "status": "accepted",
  "txid": "gateway_tx_456"
}
Return rejected with a reason if there’s an issue:
{
  "status": "rejected",
  "txid": "gateway_tx_456",
  "message": "Order already paid"
}

Headers

X-OC-ID
string
required

Sender's OCID (Opencharge ID)

X-OC-Timestamp
string
required

Unix timestamp in seconds

X-OC-Nonce
string
required

Unique request identifier for replay protection

X-OC-Signature
string
required

secp256k1 ECDSA signature of the canonical request

Body

application/json
proof
object
required
signature
string
required

secp256k1 signature of the proof from the issuer

Response

Notification received and processed

status
enum<string>
required

Whether you accepted the notification

Available options:
accepted,
rejected
txid
string
required

The transaction ID from the proof

message
string

Optional message explaining rejection reason