Skip to Content

Webhooks

TaskOn can send reward distribution callbacks to your backend webhook endpoint.

Use this when you need to:

  • Sync reward records into your own database
  • Trigger internal workflows after reward distribution
  • Build reconciliation dashboards for reward operations

Retry and Idempotency

  • TaskOn retries failed callbacks up to 3 times
  • Your endpoint should be idempotent (same payload may arrive multiple times)

Authentication Headers

Webhook requests include:

  • X-API-Key — your unique API key identifier
  • X-Signature — HMAC-SHA256 signature for request verification

You should verify both before processing payloads.

Signature Verification

The signature is not computed over the whole payload. It is an HMAC-SHA256 of a small JSON object containing only user_id and timestamp, signed with your secret key.

The exact signed string is {"timestamp":<ts>,"user_id":<id>} — keys in alphabetical order, integer values, and no whitespace:

{"timestamp":1758251308,"user_id":11111}

Key order matters. JSON.stringify serializes keys in insertion order, not alphabetical order, so you must construct the object with timestamp before user_id (or sort the keys explicitly). TaskOn’s backend signs with Go’s json.Marshal on a map, which sorts keys alphabetically and yields exactly the string above. Any difference in key order, value type, or spacing produces a different signature.

Node.js verification:

import crypto from "node:crypto"; function verifyWebhookSignature( payload: { user_id: number; timestamp: number }, signature: string, secretKey: string, ): boolean { // Keys MUST be alphabetical to match TaskOn: {"timestamp":...,"user_id":...} const signaturePayload = { timestamp: payload.timestamp, user_id: payload.user_id, }; const expected = crypto .createHmac("sha256", secretKey) .update(JSON.stringify(signaturePayload)) .digest("hex"); // Constant-time comparison to avoid timing attacks. // Guard length first: timingSafeEqual throws on unequal-length buffers. const sigBuf = Buffer.from(signature); const expBuf = Buffer.from(expected); if (sigBuf.length !== expBuf.length) return false; return crypto.timingSafeEqual(sigBuf, expBuf); }

Go verification (server side):

func verifySignature(userID, timestamp int64, signature, secretKey string) bool { payload := map[string]interface{}{ "user_id": userID, "timestamp": timestamp, } data, _ := json.Marshal(payload) // Go sorts map keys alphabetically h := hmac.New(sha256.New, []byte(secretKey)) h.Write(data) expected := hex.EncodeToString(h.Sum(nil)) return hmac.Equal([]byte(signature), []byte(expected)) }

Payload Type

@taskon/embed exports WebhookPayload:

import type { WebhookPayload } from "@taskon/embed";
interface WebhookPayload { task_id: number; reward_type: "Token" | "GTCPoints"; reward_amount: string; token_contract?: string; token_symbol?: string; token_network?: string; point_name?: string; user_id: number; evm_address: string[]; email: string; timestamp: number; }

Field Details

FieldTypeDescription
task_idnumberCompleted task ID
reward_type"Token" | "GTCPoints"Reward category
reward_amountstringReward amount
token_contractstring | undefinedToken contract (token reward only)
token_symbolstring | undefinedToken symbol (token reward only)
token_networkstring | undefinedToken network (token reward only)
point_namestring | undefinedPoint system name (points reward only)
user_idnumberUser who completed the task (used in signature)
evm_addressstring[]Single-element array containing the user’s primary EVM address
emailstringUser’s email address
timestampnumberUnix timestamp of reward distribution (used in signature)

Payload Examples

Token Reward Example

{ "task_id": 111111, "reward_type": "Token", "reward_amount": "0.1", "token_contract": "0x0000...", "token_symbol": "USDT", "token_network": "bsc", "user_id": 11111, "evm_address": ["0xaaaaaaaaa"], "email": "[email protected]", "timestamp": 1758251308 }

Points Reward Example

{ "task_id": 222222, "reward_type": "GTCPoints", "reward_amount": "100", "point_name": "PointNameA", "user_id": 11111, "evm_address": ["0xaaaaaaaaa"], "email": "[email protected]", "timestamp": 1758251308 }

Note: rewards are only sent when a user actually receives a reward. Some tasks (e.g. Proof-of-Work requiring manual review) do not distribute on submit.

Expected Response

Your endpoint must return a JSON body indicating processing status:

{ "result": true, "error": null }
  • result: true when processed successfully
  • error: null on success, otherwise an error string

Minimal Endpoint Example

import type { WebhookPayload } from "@taskon/embed"; export async function POST(req: Request) { const apiKey = req.headers.get("x-api-key"); const signature = req.headers.get("x-signature"); if (!apiKey || !signature) { return Response.json({ result: false, error: "Missing auth headers" }, { status: 401 }); } // TODO: verify apiKey and signature against your webhook secret policy const payload = (await req.json()) as WebhookPayload; // TODO: idempotency guard by task_id + reward_type + reward_amount // TODO: persist data and trigger business workflows console.log("Webhook reward payload:", payload); return Response.json({ result: true, error: null }); }

Complete Implementation Example

A full Express endpoint with API key + signature verification and reward-type handling:

const crypto = require("crypto"); const express = require("express"); const app = express(); app.use(express.json()); function verifyWebhookSignature(payload, signature, secretKey) { // Alphabetical key order to match TaskOn: {"timestamp":...,"user_id":...} const signaturePayload = { timestamp: payload.timestamp, user_id: payload.user_id, }; const expected = crypto .createHmac("sha256", secretKey) .update(JSON.stringify(signaturePayload)) .digest("hex"); const sigBuf = Buffer.from(signature || "", "utf8"); const expBuf = Buffer.from(expected, "utf8"); return ( sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf) ); } app.post("/webhook/reward-notification", (req, res) => { try { const apiKey = req.headers["x-api-key"]; const signature = req.headers["x-signature"]; if (apiKey !== process.env.EXPECTED_API_KEY) { return res.status(401).json({ result: false, error: "Invalid API key" }); } if (!verifyWebhookSignature(req.body, signature, process.env.SECRET_KEY)) { return res.status(401).json({ result: false, error: "Invalid signature" }); } const { reward_type, reward_amount, token_symbol, point_name, email } = req.body; // Idempotency guard (e.g. by task_id + timestamp) goes here if (reward_type === "Token") { console.log(`${email} received ${reward_amount} ${token_symbol}`); } else if (reward_type === "GTCPoints") { console.log(`${email} received ${reward_amount} ${point_name} points`); } // Persist + trigger your business workflows here res.json({ result: true, error: null }); } catch (error) { console.error("Webhook processing error:", error); res.status(500).json({ result: false, error: error.message }); } });

Testing Your Endpoint

Generate a test signature, then send a request with curl.

# Generate a signature for the test payload node -e " const crypto = require('crypto'); const secretKey = 'your-secret-key'; const payload = { timestamp: 1758251308, user_id: 11111 }; console.log(crypto.createHmac('sha256', secretKey) .update(JSON.stringify(payload)).digest('hex')); "
# Send a token reward webhook curl --location 'https://your-api-domain.com/webhook/reward-notification' \ --header 'Content-Type: application/json' \ --header 'X-API-Key: your-test-api-key' \ --header 'X-Signature: your-generated-signature' \ --data '{ "task_id": 111111, "reward_type": "Token", "reward_amount": "0.1", "token_contract": "0x0000...", "token_symbol": "USDT", "token_network": "bsc", "user_id": 11111, "evm_address": ["0xaaaaaaaaa"], "email": "[email protected]", "timestamp": 1758251308 }'

Also test failure paths by sending an invalid X-API-Key or X-Signature — both should be rejected with 401.

Security Best Practices

  • Verify both API key and signature on every request; never trust the body alone.
  • Constant-time comparison for signatures (crypto.timingSafeEqual / hmac.Equal) to avoid timing attacks.
  • Store secrets (EXPECTED_API_KEY, SECRET_KEY) in env vars / a secret manager, never in code.
  • Idempotency: the same notification may arrive multiple times (retries) — dedupe by task_id + timestamp.
  • Respond fast (under ~30s); move heavy work to background jobs.
  • Privacy: handle email / evm_address in line with your privacy obligations.

Troubleshooting

  • 401 invalid signature — secret key must be identical on both sides; serialize the signature payload as exactly {"timestamp":<ts>,"user_id":<id>} (alphabetical keys, no extra spaces); confirm HMAC-SHA256.
  • 401 missing headers — header names are case-insensitive in most frameworks, but ensure both X-API-Key and X-Signature are present.
  • Webhook not received — endpoint must be public, accept POST, and return the { result, error } body; use a tunnel (e.g. ngrok) to test locally.
  • Duplicate notifications — expected (retry logic); make processing idempotent.
  • Still stuck? Contact TaskOn support with your endpoint URL, sample request/response logs (headers included), and your signature-verification code — without exposing secret keys.
Last updated on