Skip to content

Webhook Setup & Verification

Requirements and recommendations for receiving Sui blockchain webhooks.

⚠️ Beta Feature: Webhooks are currently in beta. While fully functional, some features are still being refined.

💡 Prerequisites: You'll need an Inodra account and an API key to set up webhooks.

Plan Requirements

ℹ️ Available on All Plans: Webhooks are available on all plans including Free tier.

FeatureRequired Plan
WebhooksFree+
Webhook ReplayBusiness+

View pricing and upgrade →

Webhook Limits by Plan

PlanMax WebhooksLog Retention
Free1None
Team37 days
Professional1530 days
Business2590 days
EnterpriseCustomCustom

Usage & Costs

Webhook deliveries consume Compute Units (CU) from your organization's monthly quota.

ActionCU Cost
Successful webhook delivery100 CU
Each retry attempt10 CU
Manual replay100 CU

Key details:

  • First successful delivery: 100 CU
  • Each retry attempt (failed deliveries): 10 CU per attempt
  • Maximum cost per webhook: 190 CU (100 CU first attempt + 9 retries × 10 CU)
  • Manual replays that succeed will consume 100 CU
  • Usage is tracked per organization and counts toward your monthly CU limit
  • View your current usage in the Usage section of your dashboard

💡 Monitor Your Usage: If you're receiving high-volume events, monitor your CU consumption in the dashboard to avoid unexpected quota limits. Consider using event field filters or filtering by sender to reduce webhook volume.


Setting Up Your Webhook Endpoint

Endpoint Requirements

Your webhook endpoint must:

  • Accept POST with Content-Type: application/json
  • Return HTTP 2xx status (200-299) to acknowledge successful delivery
  • Handle duplicates using X-Dedupe-Key header
  • Use HTTPS in production
  • Respond within 10 seconds (automatic retry after timeout)
  • Verify webhook signatures (strongly recommended)

Basic Example

javascript
// Express.js example
const express = require('express')
const app = express()

app.use(express.json())

app.post('/webhooks/sui', (req, res) => {
  const { payloadVersion, payload } = req.body

  try {
    // Check payload version for compatibility
    if (payloadVersion !== 1) {
      console.warn('Unknown payload version:', payloadVersion)
    }

    // Process based on webhook type
    console.log('Received webhook:', {
      version: payloadVersion,
      activityType: payload.activityType,
      txDigest: payload.txDigest,
      checkpoint: payload.checkpoint
    })

    // Your processing logic here
    processWebhook(payload)

    // Always return 200 to acknowledge receipt
    res.status(200).send('OK')
  } catch (error) {
    console.error('Error processing webhook:', error)
    res.status(500).send('Error processing')
  }
})

app.listen(3000, () => {
  console.log('Webhook server running on port 3000')
})

Webhook Signature Verification

Every webhook delivery includes an HMAC-SHA256 signature that you should verify to ensure the request genuinely came from Inodra and hasn't been tampered with.

Signature Headers

Each webhook request includes these security headers:

HeaderDescription
X-Inodra-SignatureHMAC signature in format t=<timestamp>,v1=<signature>
X-Inodra-TimestampUnix timestamp (seconds) when the webhook was sent
X-Dedupe-KeyUnique key for deduplication (e.g., txDigest:eventIndex)

Finding Your Webhook Secret

Each webhook has a unique secret that's generated when you create it:

  1. Go to Webhooks in your dashboard
  2. Find your webhook in the list
  3. Click the eye icon to reveal the secret
  4. Copy the secret (format: whsec_<64-hex-characters>)

⚠️ Keep Your Secret Safe: Your webhook secret is like a password. Never expose it in client-side code, commit it to version control, or share it publicly.

How Signatures Work

Inodra generates signatures using:

signature = HMAC-SHA256(secret, timestamp + "." + JSON.stringify(payload))

The X-Inodra-Signature header contains:

  • t - The Unix timestamp when the signature was generated
  • v1 - The HMAC-SHA256 signature (hex-encoded)

Verifying Signatures

Node.js / JavaScript

javascript
const crypto = require('crypto')

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
  // Parse the signature header: "t=1234567890,v1=abc123..."
  // Handle optional whitespace after commas
  const parts = {}
  for (const part of signatureHeader.split(',')) {
    const [key, value] = part.trim().split('=')
    parts[key] = value
  }
  const timestamp = parts['t']
  const receivedSignature = parts['v1']

  if (!timestamp || !receivedSignature) {
    return false
  }

  // IMPORTANT: Use the raw request body, not JSON.stringify(parsedBody)
  // Re-stringifying can change whitespace, key order, or number formatting
  const signedPayload = `${timestamp}.${rawBody}`

  // Compute expected signature
  const expectedSignature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex')

  // Convert to buffers for comparison
  const receivedBuffer = Buffer.from(receivedSignature, 'hex')
  const expectedBuffer = Buffer.from(expectedSignature, 'hex')

  // Check lengths match before timing-safe comparison (timingSafeEqual throws on length mismatch)
  if (receivedBuffer.length !== expectedBuffer.length) {
    return false
  }

  // Use timing-safe comparison to prevent timing attacks
  const isValid = crypto.timingSafeEqual(receivedBuffer, expectedBuffer)

  // Optional: Check timestamp to prevent replay attacks (5 minute tolerance)
  const currentTime = Math.floor(Date.now() / 1000)
  const webhookTime = parseInt(timestamp, 10)
  const isTimestampValid = Math.abs(currentTime - webhookTime) < 300

  return isValid && isTimestampValid
}

// Express.js example with signature verification
// IMPORTANT: Use express.raw() to get the raw body bytes, not express.json()
app.post('/webhooks/sui', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-inodra-signature']
  const secret = process.env.WEBHOOK_SECRET // whsec_...

  // req.body is a Buffer when using express.raw()
  const rawBody = req.body.toString('utf8')

  if (!verifyWebhookSignature(rawBody, signature, secret)) {
    console.error('Invalid webhook signature')
    return res.status(401).json({ error: 'Invalid signature' })
  }

  // Parse the JSON after verification
  const payload = JSON.parse(rawBody)

  // Signature valid - process the webhook
  console.log('Verified webhook:', payload)
  res.status(200).json({ received: true })
})

Python

python
import hmac
import hashlib
import json
import time
import os
from flask import Flask, request, jsonify

app = Flask(__name__)

def verify_webhook_signature(raw_body, signature_header, secret):
    # Parse signature header: "t=1234567890,v1=abc123..."
    # Handle optional whitespace after commas
    parts = {}
    for part in signature_header.split(','):
        key, value = part.strip().split('=', 1)
        parts[key] = value

    timestamp = parts.get('t')
    received_signature = parts.get('v1')

    if not timestamp or not received_signature:
        return False

    # IMPORTANT: Use the raw request body, not json.dumps(parsed_body)
    # Re-serializing can change whitespace, key order, or number formatting
    signed_payload = f"{timestamp}.{raw_body}"

    # Compute expected signature
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Timing-safe comparison (hmac.compare_digest handles length differences safely)
    is_valid = hmac.compare_digest(received_signature, expected_signature)

    # Check timestamp (5 minute tolerance)
    current_time = int(time.time())
    webhook_time = int(timestamp)
    is_timestamp_valid = abs(current_time - webhook_time) < 300

    return is_valid and is_timestamp_valid

@app.route('/webhooks/sui', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Inodra-Signature')
    secret = os.environ.get('WEBHOOK_SECRET')  # whsec_...

    # IMPORTANT: Use get_data() to get raw body bytes, not request.json
    raw_body = request.get_data(as_text=True)

    if not verify_webhook_signature(raw_body, signature, secret):
        return jsonify({'error': 'Invalid signature'}), 401

    # Parse the JSON after verification
    payload = json.loads(raw_body)

    # Signature valid - process the webhook
    print('Verified webhook:', payload)
    return jsonify({'received': True}), 200

Why Verify Signatures?

  1. Authentication: Confirms the webhook came from Inodra, not a malicious actor
  2. Integrity: Ensures the payload hasn't been modified in transit
  3. Replay Protection: Timestamp validation prevents old webhooks from being replayed

💡 Always Verify in Production: While signature verification is optional during development, you should always verify signatures in production to protect against webhook spoofing attacks.


Retry System

Inodra uses an automatic retry system for failed webhook deliveries.

What Gets Retried

Automatic Retries:

  • Network errors: Connection failures, DNS errors, socket timeouts
  • Timeouts: Requests exceeding 10 seconds
  • 5xx Server errors: 500-599 status codes (temporary server issues)
  • 408 Request Timeout: Your endpoint took too long to respond
  • 429 Too Many Requests: Rate limiting - we'll back off and retry

No Retries (Permanent Failures):

  • 400 Bad Request: Malformed webhook payload (contact support if you see this)
  • 401 Unauthorized: Authentication failure at your endpoint
  • 403 Forbidden: Permission denied
  • 404 Not Found: Webhook URL doesn't exist or was removed
  • 405 Method Not Allowed: Endpoint doesn't accept POST requests
  • Other 4xx errors: Client-side configuration issues that won't fix themselves

Retry Schedule

  • Success criteria: HTTP status 200-299 (webhook marked as delivered)
  • Maximum attempts: 10 total delivery attempts (1 initial + 9 retries)
  • Backoff schedule: Exponential backoff
  • Total retry window: Approximately 17 hours from first attempt

Example retry timeline:

Attempt 1:  Immediate (initial delivery)
Attempt 2:  +1 minute
Attempt 3:  +2 minutes  (+3min total)
Attempt 4:  +4 minutes  (+7min total)
Attempt 5:  +8 minutes  (+15min total)
Attempt 6:  +16 minutes (+31min total)
Attempt 7:  +32 minutes (~1 hour total)
Attempt 8:  +64 minutes (~2 hours total)
Attempt 9:  +128 minutes (~4 hours total)
Attempt 10: +256 minutes (~8.5 hours total)
Final:      +512 minutes (~17 hours total)

If all 10 attempts fail, the webhook is marked as permanently failed and automatically paused. You'll receive an email notification when this happens, and you can re-enable the webhook from your dashboard after fixing the issue.


Registering Webhooks

Using the Dashboard

  1. Log into your dashboard at inodra.com/dashboard
  2. Navigate to "Webhooks" in the sidebar
  3. Click "Add Webhook" and select the webhook type
  4. Fill out the form based on webhook type:
  5. Click "Create" to save your webhook

Webhook Management

Once created, you can manage your webhooks:

  • Edit: Update webhook URL, filters, or configuration
  • Pause/Resume: Temporarily stop receiving webhooks without deleting
  • Delete: Permanently remove the webhook
  • View Status: See if the webhook is Active or Paused
  • Delivery Logs: View delivery history with status, timestamps, and retry attempts
  • Manual Replay: Replay failed or successful webhooks (Professional+ plans)

Manual Webhook Replay

ℹ️ Business Plan Required: Webhook replay is available on Business plans and above. Upgrade your plan →

If a webhook delivery fails (e.g., your service was temporarily down), you can manually replay it:

  1. Navigate to the Webhook Delivery Logs page
  2. Find the delivery in the list
  3. Click the Replay button
  4. For successful webhooks, confirm you want to replay (prevents accidental duplicates)

How it works:

  • Original delivery is canceled (prevents duplicate sends from scheduled retries)
  • New delivery is queued with the exact same payload
  • Fresh retry counter (up to 10 more attempts if needed)
  • Idempotent - safe to replay multiple times

When to use:

  • Your service was temporarily down and missed webhooks
  • You need to reprocess specific events
  • Testing webhook endpoints after configuration changes

Delivery Logs

What's Logged

Each delivery attempt records:

  • Webhook Type: event, address, coin, or object
  • Monitored Value: Event type, address, coin type, or object ID
  • Checkpoint: Blockchain checkpoint number
  • Transaction: Transaction digest
  • Status: success, failed, or retrying
  • HTTP Status Code: Response code from your endpoint
  • Error Message: Detailed error information (for failures)
  • Retry Count: Current retry attempt number (0-9)
  • Timestamps: Created, delivered, and next retry times

Log Retention by Plan

PlanRetention
FreeNone (logs not stored)
Team7 days
Professional30 days
Business90 days
EnterpriseCustom

Common Issues

Webhook Not Receiving Data

  1. Check endpoint URL: Ensure it's accessible and using HTTPS
  2. Verify filters: Make sure your filters aren't too restrictive
  3. Check delivery logs: Review delivery history for error details
  4. Test connectivity: Verify your endpoint responds to POST requests with 2xx status

Timeout Issues

  1. Optimize processing: Keep webhook handlers fast (< 10 seconds)
  2. Use async processing: Queue webhooks for background processing
  3. Return 2xx status quickly: Acknowledge receipt immediately
  4. Monitor retry attempts: Check delivery logs if experiencing repeated failures

Duplicate Webhooks

Use the X-Dedupe-Key header for idempotency. The key format is typically txDigest:eventIndex for events, ensuring each blockchain event is uniquely identified.

⚠️ Production Warning: Don't use in-memory storage (like Set()) for deduplication in production. It won't survive restarts and won't work across multiple server instances.

javascript
const Redis = require('ioredis')
const redis = new Redis(process.env.REDIS_URL)

// Store dedupe keys for 24 hours (webhook retry window is ~17 hours)
const DEDUPE_TTL_SECONDS = 86400

app.post('/webhooks/sui', express.raw({ type: 'application/json' }), async (req, res) => {
  const dedupeKey = req.headers['x-dedupe-key']

  if (!dedupeKey) {
    // No dedupe key - process anyway but log warning
    console.warn('Webhook received without X-Dedupe-Key header')
  } else {
    // Try to set the key with NX (only if not exists)
    const isNew = await redis.set(`webhook:${dedupeKey}`, '1', 'EX', DEDUPE_TTL_SECONDS, 'NX')

    if (!isNew) {
      // Key already exists - this is a duplicate
      console.log(`Duplicate webhook skipped: ${dedupeKey}`)
      return res.status(200).send('Already processed')
    }
  }

  // Process the webhook
  const rawBody = req.body.toString('utf8')
  const payload = JSON.parse(rawBody)
  await processWebhook(payload)

  res.status(200).send('OK')
})

Database Example (PostgreSQL)

javascript
// Using a simple dedupe table: CREATE TABLE webhook_dedupe (key TEXT PRIMARY KEY, created_at TIMESTAMP DEFAULT NOW())
// Add a scheduled job to clean up old entries: DELETE FROM webhook_dedupe WHERE created_at < NOW() - INTERVAL '24 hours'

app.post('/webhooks/sui', express.raw({ type: 'application/json' }), async (req, res) => {
  const dedupeKey = req.headers['x-dedupe-key']

  if (dedupeKey) {
    try {
      // INSERT with ON CONFLICT DO NOTHING - returns 0 rows if key exists
      const result = await db.query(
        'INSERT INTO webhook_dedupe (key) VALUES ($1) ON CONFLICT DO NOTHING',
        [dedupeKey]
      )

      if (result.rowCount === 0) {
        return res.status(200).send('Already processed')
      }
    } catch (error) {
      console.error('Dedupe check failed:', error)
      // Continue processing - better to risk duplicate than drop webhook
    }
  }

  const rawBody = req.body.toString('utf8')
  const payload = JSON.parse(rawBody)
  await processWebhook(payload)

  res.status(200).send('OK')
})

Best Practices

  1. Always verify signatures in production to protect against spoofing
  2. Respond quickly (< 10 seconds) to avoid timeouts and retries
  3. Use async processing for complex operations - acknowledge receipt first
  4. Implement idempotency using the X-Dedupe-Key header
  5. Monitor your logs regularly to catch delivery issues early
  6. Handle errors gracefully and return appropriate status codes
  7. Store webhook secrets securely as environment variables
  8. Use field filters to reduce high-volume event webhooks

Released under the MIT License.