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.
| Feature | Required Plan |
|---|---|
| Webhooks | Free+ |
| Webhook Replay | Business+ |
Webhook Limits by Plan
| Plan | Max Webhooks | Log Retention |
|---|---|---|
| Free | 1 | None |
| Team | 3 | 7 days |
| Professional | 15 | 30 days |
| Business | 25 | 90 days |
| Enterprise | Custom | Custom |
Usage & Costs
Webhook deliveries consume Compute Units (CU) from your organization's monthly quota.
| Action | CU Cost |
|---|---|
| Successful webhook delivery | 100 CU |
| Each retry attempt | 10 CU |
| Manual replay | 100 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-Keyheader - Use HTTPS in production
- Respond within 10 seconds (automatic retry after timeout)
- Verify webhook signatures (strongly recommended)
Basic Example
// 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:
| Header | Description |
|---|---|
X-Inodra-Signature | HMAC signature in format t=<timestamp>,v1=<signature> |
X-Inodra-Timestamp | Unix timestamp (seconds) when the webhook was sent |
X-Dedupe-Key | Unique key for deduplication (e.g., txDigest:eventIndex) |
Finding Your Webhook Secret
Each webhook has a unique secret that's generated when you create it:
- Go to Webhooks in your dashboard
- Find your webhook in the list
- Click the eye icon to reveal the secret
- 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 generatedv1- The HMAC-SHA256 signature (hex-encoded)
Verifying Signatures
Node.js / 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
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}), 200Why Verify Signatures?
- Authentication: Confirms the webhook came from Inodra, not a malicious actor
- Integrity: Ensures the payload hasn't been modified in transit
- 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
- Log into your dashboard at inodra.com/dashboard
- Navigate to "Webhooks" in the sidebar
- Click "Add Webhook" and select the webhook type
- Fill out the form based on webhook type:
- Event Webhooks - Event type, optional sender filter, optional field filters
- Address Webhooks - Address to monitor
- Coin Webhooks - Address and optional coin type
- Object Webhooks - Object ID to monitor
- 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:
- Navigate to the Webhook Delivery Logs page
- Find the delivery in the list
- Click the Replay button
- 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, orretrying - 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
| Plan | Retention |
|---|---|
| Free | None (logs not stored) |
| Team | 7 days |
| Professional | 30 days |
| Business | 90 days |
| Enterprise | Custom |
Common Issues
Webhook Not Receiving Data
- Check endpoint URL: Ensure it's accessible and using HTTPS
- Verify filters: Make sure your filters aren't too restrictive
- Check delivery logs: Review delivery history for error details
- Test connectivity: Verify your endpoint responds to POST requests with 2xx status
Timeout Issues
- Optimize processing: Keep webhook handlers fast (< 10 seconds)
- Use async processing: Queue webhooks for background processing
- Return 2xx status quickly: Acknowledge receipt immediately
- 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.
Redis Example (Recommended)
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)
// 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
- Always verify signatures in production to protect against spoofing
- Respond quickly (< 10 seconds) to avoid timeouts and retries
- Use async processing for complex operations - acknowledge receipt first
- Implement idempotency using the
X-Dedupe-Keyheader - Monitor your logs regularly to catch delivery issues early
- Handle errors gracefully and return appropriate status codes
- Store webhook secrets securely as environment variables
- Use field filters to reduce high-volume event webhooks