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 identifierX-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.stringifyserializes keys in insertion order, not alphabetical order, so you must construct the object withtimestampbeforeuser_id(or sort the keys explicitly). TaskOn’s backend signs with Go’sjson.Marshalon 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
| Field | Type | Description |
|---|---|---|
task_id | number | Completed task ID |
reward_type | "Token" | "GTCPoints" | Reward category |
reward_amount | string | Reward amount |
token_contract | string | undefined | Token contract (token reward only) |
token_symbol | string | undefined | Token symbol (token reward only) |
token_network | string | undefined | Token network (token reward only) |
point_name | string | undefined | Point system name (points reward only) |
user_id | number | User who completed the task (used in signature) |
evm_address | string[] | Single-element array containing the user’s primary EVM address |
email | string | User’s email address |
timestamp | number | Unix 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:truewhen processed successfullyerror:nullon 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_addressin 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-KeyandX-Signatureare 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.