Webhook Best Practices

Webhooks are the recommended way to track transaction status in Neutron. This guide explains why webhooks outperform polling, how to set them up, and how to handle edge cases.

Prerequisites

Before using any Neutron integration (API, SDK, or MCP), you need an API key. You get this from the dashboard at portal.neutron.me.

  1. Sign up at portal.neutron.me
  2. Access your dashboard
  3. Generate your API key and secret

Everything can be configured, but the API key is the final piece — without it, wallet services won't function.

Note: Keep your API secret secure. Never commit it to version control or expose it in client-side code.

Why Webhooks Over Polling?

WebhooksPolling
LatencySub-second1–30s depending on interval
Rate limitsNot affectedConsumes your quota
ReliabilityPush delivery with retriesYou manage retry logic
ComplexityOne-time setupOngoing loop to maintain

Polling the /api/v2/transaction/{txnId} endpoint works, but burns through your rate limit allowance quickly — especially for high-volume integrations. A busy integration polling every 2 seconds for 10 concurrent transactions makes 300 requests per minute. Webhooks deliver the same information in a single push.

Rate Limit Reference

EndpointLimit
GET /api/v2/transaction/:id60 req/min per API key
GET /api/v2/transaction30 req/min per API key
POST /api/v2/webhook10 req/min per API key

Once you hit the limit, you'll receive 429 Too Many Requests responses until the window resets.


Setting Up Webhooks

Via the API

curl -X POST https://api.neutron.me/api/v2/webhook \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -d '{
    "callback": "https://yourapp.com/webhooks/neutron",
    "secret": "your-webhook-secret"
  }'

Store the webhook secret securely — you'll use it to verify incoming payloads.

Via the SDK

const { Neutron } = require('neutron-sdk');

const neutron = new Neutron({
  apiKey: process.env.NEUTRON_API_KEY,
  apiSecret: process.env.NEUTRON_API_SECRET,
});

const webhook = await neutron.webhooks.create({
  callback: 'https://yourapp.com/webhooks/neutron',
  secret: process.env.WEBHOOK_SECRET,
});

console.log('Webhook created:', webhook.webhookId);

Via the MCP Tool (AI Agents)

Use the neutron_create_webhook tool:
  callback: "https://yourapp.com/webhooks/neutron"
  secret: "your-webhook-secret"

Webhook Payload

When a transaction status changes, Neutron POSTs to your callback URL:

{
  "txnId": "41866d80-a46a-4845-a205-5cffc881cad3",
  "extRefId": "order-abc1234",
  "txnState": "completed",
  "msg": "",
  "updatedAt": 1676030467492
}

Transaction states:

  • quoted — Created, awaiting confirmation
  • processing — Confirmed, payment in flight
  • completed — Payment successful
  • failed — Payment failed
  • expired — Invoice or transaction timed out

Verifying Webhook Signatures

Always verify the signature. Without verification, anyone can POST fake events to your endpoint.

Neutron signs every webhook payload with HMAC-SHA256 using your webhook secret. The signature is sent in the X-Neutronpay-Signature header.

Node.js

const crypto = require('crypto');
const express = require('express');
const app = express();

app.post('/webhooks/neutron', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-neutronpay-signature'];
  const secret = process.env.WEBHOOK_SECRET;

  // Compute expected signature
  const expected = crypto
    .createHmac('sha256', secret)
    .update(req.body) // raw body bytes, not parsed JSON
    .digest('hex');

  // Timing-safe comparison
  const valid = crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex')
  );

  if (!valid) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(req.body);
  console.log('Verified event:', event.txnId, event.txnState);

  // Handle the event
  if (event.txnState === 'completed') {
    // Fulfill order, release funds, etc.
  }

  res.status(200).json({ received: true });
});

Python

import hmac
import hashlib
import json
from flask import Flask, request, abort

app = Flask(__name__)

@app.route('/webhooks/neutron', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Neutronpay-Signature', '')
    secret = os.environ['WEBHOOK_SECRET'].encode()

    expected = hmac.new(secret, request.data, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(signature, expected):
        abort(401)

    event = request.json
    print(f"Event: {event['txnId']} → {event['txnState']}")

    return {'received': True}, 200

Via the SDK

const { Neutron } = require('neutron-sdk');

// In your webhook handler:
app.post('/webhooks/neutron', express.raw({ type: 'application/json' }), (req, res) => {
  const isValid = Neutron.webhooks.verify(
    req.body,                                    // raw body
    req.headers['x-neutronpay-signature'],          // signature header
    process.env.WEBHOOK_SECRET                   // your secret
  );

  if (!isValid) return res.status(401).end();

  const event = JSON.parse(req.body);
  // handle event...
  res.status(200).json({ received: true });
});

Responding to Webhooks

Your endpoint must:

  1. Return 200 OK within 10 seconds
  2. Process asynchronously — queue the event and return immediately if processing takes time
  3. Be idempotent — you may receive the same event more than once
app.post('/webhooks/neutron', async (req, res) => {
  // Respond immediately
  res.status(200).json({ received: true });

  // Process asynchronously
  const event = JSON.parse(req.body);
  await queue.push(event); // handle in background
});

If your endpoint returns a non-200 status or times out, Neutron will retry delivery with exponential backoff.


Fallback: SSE for Ephemeral Agents

Some AI agents and serverless functions don't have a public URL to receive webhook POSTs. For these cases, use Server-Sent Events (SSE) to stream status updates directly.

Via the MCP Tool

Use neutron_subscribe_status:
  transactionId: "txn-abc123"
  timeoutSeconds: 60

The tool connects to the Neutron SSE stream and returns when the transaction reaches a terminal state (completed, failed, expired).

Via the SDK

// Stream status updates until terminal state
for await (const event of neutron.transactions.subscribe('txn-abc123')) {
  console.log('Status update:', event.status);

  if (['completed', 'failed', 'expired'].includes(event.status)) {
    break;
  }
}

When to Use SSE vs Webhooks

ScenarioRecommended
Long-running serverWebhooks
Serverless / LambdaWebhooks (with public URL)
Ephemeral AI agentSSE
Local development / testingSSE
High volume (>100 tx/min)Webhooks

Checklist

  • API key obtained from portal.neutron.me
  • Webhook endpoint returns 200 within 10 seconds
  • Signature verification implemented with timingSafeEqual
  • Raw request body used for signature (not re-serialized JSON)
  • Idempotency handled (same event may arrive twice)
  • Webhook secret stored in env var, not hardcoded
  • Fallback to SSE for agents without public endpoints