Backend Registration
A backend registers with the Manager so the Manager knows where to send trigger callbacks: HTTP POSTs that fire on a cron schedule you configure in the Manager. @productifyfw/node-express and the Go library do all of this for you; the steps below are what they do.
One registration per deployment, not per tenant
A backend registers once per (application, environment) using an environment-scoped machine user, not once per tenant. A trigger fires once per deployment; the backend then lists the tenants that have the application enabled (GET /api/machine/application/:id/tenants, see Multi-tenant fan-out) and does per-tenant work itself.
Overview
Registration gives a backend four things: POST callbacks when triggers execute; a token the Manager uses to authenticate those callbacks; an entry in the Manager's registry of active backends, kept alive via heartbeats; and routing, since callbacks are delivered to the backends registered for the trigger's application.
Prerequisites
Before registering a backend, you need a machine user (the service account the backend authenticates with), an HTTPS callback endpoint that receives trigger notifications, and network access from the Manager to that callback URL.
See Machine Users for creating service accounts.
Registration Process
1. Create Machine User
First, create a machine user for your backend service:
mutation {
createMachineUserWithCredentials(
environmentId: "environment-uuid"
input: {
name: "Payment Processing Service"
username: "Bearer"
hashedKey: "__GENERATE_TOKEN__"
enabled: true
}
) {
generatedToken
machineUser {
id
username
}
}
}The machine user belongs to an environment (environmentId), not to a tenant. hashedKey: "__GENERATE_TOKEN__" asks the Manager to generate a Bearer token; in that case the username you pass is replaced by the Manager with Bearer:<token prefix>, so any value works. Pass a real username and a password as hashedKey only for a Basic-auth machine user, which the Manager's own /api/machine/* routes do not accept (see Authentication).
TIP
Save the generatedToken immediately - it's only shown once.
2. Register Backend
Use the machine user token to register your backend:
curl -X POST https://manager.example.com/api/machine/register-backend \
-H "Authorization: Bearer <machine-user-token>" \
-H "Content-Type: application/json" \
-d '{
"application_id": "application-uuid",
"callback_url": "https://payment.example.com/triggers/callback",
"version": "1.0.0"
}'Response:
{
"success": true,
"registered_id": "backend-uuid",
"token": "callback-auth-token",
"message": "Backend registered successfully",
"environment_id": "environment-uuid",
"application_id": "application-uuid"
}Store the returned token: the Manager sends it as Authorization: Bearer <token> with every trigger callback, so your handler can verify the caller. A first registration answers 201; registering again for the same application with the same machine user answers 200, updates the callback URL and version, refreshes the heartbeat, and issues a new token. The old token stops working, so always keep the token from the latest response.
3. Implement Callback Handler
Create an endpoint at your callback URL to receive trigger notifications:
// Node.js/Express example
app.post("/triggers/callback", async (req, res) => {
const { trigger_id, trigger_name, cron_expression, run_key, executed_at } =
req.body;
console.log(`Trigger ${trigger_name} (${run_key}) fired at ${executed_at}`);
try {
// Execute your business logic
await processPayments();
// Respond with success
res.json({
status: "success",
message: "Payment processing completed",
processedAt: new Date().toISOString(),
});
} catch (error) {
console.error("Trigger execution failed:", error);
// Respond with error
res.status(500).json({
status: "error",
message: error.message,
});
}
});Use the integration library
For Express backends, @productifyfw/node-express provides registerBackend() and createTriggerHandler() which implement registration, heartbeats, token validation, and payload dispatch for you. See the Backend Integration guide.
Callback Payload
When a trigger executes, the Manager sends this payload to your callback URL:
{
"trigger_id": "trigger-uuid",
"trigger_name": "Hourly Payment Processing",
"cron_expression": "0 * * * *",
"run_key": "hourly-payment",
"executed_at": "2025-12-01T10:00:00Z"
}Payload Fields
trigger_id: UUID of the trigger that firedtrigger_name: human-readable trigger namecron_expression: the schedulerun_key: the operator-chosen routing key for the triggerexecuted_at: ISO 8601 timestamp of the occurrence; deduplicate on(trigger_id, executed_at)
Expected Response
The Manager treats any 2xx status code as a successful delivery; the response body is not interpreted. Non-2xx responses are logged as dispatch errors.
Response Time
The Manager's dispatch client uses a 5 second timeout, so respond quickly. Process heavy workloads asynchronously.
Multi-tenant fan-out
A trigger callback is tenant-agnostic: the Manager fires it once per deployment, and the payload carries no tenant ID. When the work is per-customer, the backend lists the tenants the application is enabled for and iterates them itself:
curl https://manager.example.com/api/machine/application/<application-id>/tenants \
-H "Authorization: Bearer <machine-user-token>"{
"success": true,
"tenants": [
{ "id": "tenant-a-uuid", "name": "Customer A" },
{ "id": "tenant-b-uuid", "name": "Customer B" }
]
}Loop over the returned tenants and do the work for each one. This keeps one registration and one trigger row per deployment while still doing per-tenant work.
Implementation Examples
Node.js with Express
const express = require("express");
const app = express();
app.use(express.json());
// Trigger callback handler
app.post("/triggers/callback", async (req, res) => {
const { trigger_id, trigger_name, run_key, executed_at } = req.body;
// Log trigger execution
console.log(`[${new Date().toISOString()}] Trigger: ${trigger_name}`);
console.log(` Run key: ${run_key}, executed at: ${executed_at}`);
try {
// Process trigger
const result = await handleTrigger(trigger_id, run_key);
res.json({
status: "success",
message: "Trigger processed successfully",
result,
});
} catch (error) {
console.error("Trigger processing error:", error);
res.status(500).json({
status: "error",
message: error.message,
});
}
});
async function handleTrigger(triggerId, runKey) {
// Your business logic here
return { processed: true };
}
app.listen(3000, () => {
console.log("Backend listening on port 3000");
});Python with Flask
from flask import Flask, request, jsonify
import logging
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
@app.route('/triggers/callback', methods=['POST'])
def trigger_callback():
data = request.json
trigger_id = data['trigger_id']
trigger_name = data['trigger_name']
run_key = data['run_key']
executed_at = data['executed_at']
logging.info(f"Trigger: {trigger_name}")
logging.info(f" Run key: {run_key}, executed at: {executed_at}")
try:
# Process trigger
result = handle_trigger(trigger_id, run_key)
return jsonify({
'status': 'success',
'message': 'Trigger processed successfully',
'result': result
})
except Exception as e:
logging.error(f'Trigger processing error: {e}')
return jsonify({
'status': 'error',
'message': str(e)
}), 500
def handle_trigger(trigger_id, run_key):
# Your business logic here
return {'processed': True}
if __name__ == '__main__':
app.run(host='0.0.0.0', port=3000)Go with Gin
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
type TriggerPayload struct {
TriggerID string `json:"trigger_id"`
TriggerName string `json:"trigger_name"`
CronExpression string `json:"cron_expression"`
RunKey string `json:"run_key"`
ExecutedAt string `json:"executed_at"`
}
func triggerCallback(c *gin.Context) {
var payload TriggerPayload
if err := c.BindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"status": "error",
"message": "Invalid payload",
})
return
}
log.Printf("Trigger: %s", payload.TriggerName)
log.Printf(" Run key: %s, executed at: %s", payload.RunKey, payload.ExecutedAt)
// Process trigger
if err := handleTrigger(payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": "error",
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Trigger processed successfully",
})
}
func handleTrigger(payload TriggerPayload) error {
// Your business logic here
return nil
}
func main() {
r := gin.Default()
r.POST("/triggers/callback", triggerCallback)
r.Run(":3000")
}Best Practices
Idempotency
Implement idempotent trigger handlers to handle duplicate deliveries:
const processedTriggers = new Set();
app.post("/triggers/callback", async (req, res) => {
const { trigger_id, executed_at } = req.body;
// Create unique key for this execution
const executionKey = `${trigger_id}-${executed_at}`;
// Check if already processed
if (processedTriggers.has(executionKey)) {
return res.json({
status: "success",
message: "Already processed",
});
}
try {
await processTrigger(req.body);
processedTriggers.add(executionKey);
res.json({ status: "success" });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});Asynchronous Processing
Respond quickly and process heavy workloads asynchronously:
const queue = require("./queue");
app.post("/triggers/callback", async (req, res) => {
// Immediately queue for processing
await queue.add("trigger-execution", req.body);
// Respond quickly
res.json({
status: "success",
message: "Queued for processing",
});
});
// Process queue asynchronously
queue.process("trigger-execution", async (job) => {
const { trigger_id, run_key } = job.data;
await heavyProcessing(trigger_id, run_key);
});Error Handling
Handle errors:
app.post('/triggers/callback', async (req, res) => {
try {
// Validate payload
if (!req.body.trigger_id || !req.body.run_key) {
throw new Error('Invalid payload structure');
}
// Process trigger
const result = await processTrigger(req.body);
res.json({ status: 'success', result });
} catch (error) {
// Log error
console.error('Trigger processing failed:', error);
// Respond with appropriate status
const statusCode = error.code === 'VALIDATION_ERROR' ? 400 : 500;
res.status(statusCode).json({
status: 'error',
message: error.message,
errorCode: error.code
});
}
});Security
Verify callbacks come from the Manager by checking the Bearer token issued at registration (strongly recommended):
const crypto = require("crypto");
// The `token` field returned by /api/machine/register-backend
const CALLBACK_TOKEN = process.env.CALLBACK_TOKEN;
function verifyCallbackToken(req) {
const authHeader = req.headers["authorization"] || "";
if (!authHeader.startsWith("Bearer ")) return false;
const received = Buffer.from(authHeader.slice(7));
const expected = Buffer.from(CALLBACK_TOKEN);
return (
received.length === expected.length &&
crypto.timingSafeEqual(received, expected)
);
}
app.post("/triggers/callback", async (req, res) => {
// Verify token
if (!verifyCallbackToken(req)) {
return res.status(401).json({
status: "error",
message: "Invalid token",
});
}
// Process trigger
// ...
});Monitoring & Logging
Logging Best Practices
const winston = require("winston");
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [new winston.transports.File({ filename: "triggers.log" })],
});
app.post("/triggers/callback", async (req, res) => {
const { trigger_id, trigger_name, run_key } = req.body;
const startTime = Date.now();
logger.info("Trigger received", {
triggerId: trigger_id,
triggerName: trigger_name,
runKey: run_key,
timestamp: new Date().toISOString(),
});
try {
await processTrigger(req.body);
logger.info("Trigger processed successfully", {
triggerId: trigger_id,
duration: Date.now() - startTime,
});
res.json({ status: "success" });
} catch (error) {
logger.error("Trigger processing failed", {
triggerId: trigger_id,
error: error.message,
stack: error.stack,
});
res.status(500).json({ status: "error", message: error.message });
}
});Metrics Collection
const prometheus = require("prom-client");
const triggerCounter = new prometheus.Counter({
name: "triggers_processed_total",
help: "Total triggers processed",
labelNames: ["status", "trigger_name"],
});
const triggerDuration = new prometheus.Histogram({
name: "trigger_processing_duration_seconds",
help: "Trigger processing duration",
});
app.post("/triggers/callback", async (req, res) => {
const end = triggerDuration.startTimer();
try {
await processTrigger(req.body);
triggerCounter.inc({
status: "success",
trigger_name: req.body.trigger_name,
});
res.json({ status: "success" });
} catch (error) {
triggerCounter.inc({
status: "error",
trigger_name: req.body.trigger_name,
});
res.status(500).json({ status: "error", message: error.message });
} finally {
end();
}
});Troubleshooting
Callbacks Not Received
Check that:
- Backend is registered via
/api/machine/register-backend - Callback URL is correct and accessible from Manager
- Firewall allows inbound connections on callback port
- HTTPS certificate is valid (if using HTTPS)
- Backend service is running and healthy
Registration Fails
Read the message field of the response; it names the problem. Common cases:
401- the machine user token is missing, wrong, disabled, or sent as Basic auth instead ofBearer <token>400 Invalid callback_url- the URL is not syntactically valid, or it points at a loopback, private, or cloud-metadata address that the Manager's SSRF guard refuses404 Application not found or does not belong to your project- theapplication_idis not in the machine user's environment's project429- too many failed authentications; wait forRetry-After- No response at all - check network connectivity from your service to the Manager
Trigger Executes But Callback Fails
Debug:
- Check backend logs for errors
- Verify payload structure matches expected format
- Ensure quick response time (< 5 seconds)
- Test callback endpoint independently
- Check for rate limiting or resource exhaustion
See Also
- Trigger System - how triggers are scheduled and dispatched
- Machine Users - Service account authentication
- REST API - REST endpoint documentation
- API Reference - Complete API reference