Backend Registration
Backend services integrate with the Manager's trigger system by registering callback URLs to receive trigger execution notifications.
One registration per deployment, not per tenant (Wave 3)
Since the Wave 3 re-architecture a backend registers once per (application, environment) through an environment-scoped machine user — not once per tenant. A trigger fires once per deployment; the backend fans out over its enabled tenants itself using GET /api/machine/application/:id/tenants (see Multi-tenant fan-out). Config clients gain a tenant dimension: getFrontendConfig(appId, tenantId) / FrontendConfigForTenant. Upgrading from per-tenant registrations? See the Migration Guide.
Overview
Backend registration enables:
- Trigger Callbacks - Receive POST requests when triggers execute
- Callback Authentication - Registration returns a token the Manager uses to authenticate its callbacks
- Service Discovery - Manager maintains a registry of active backends (kept alive via heartbeats)
- Automatic Routing - Callbacks delivered to the backends registered for the trigger's application
Prerequisites
Before registering a backend, you need:
- Machine User - Service account for authentication
- Callback Endpoint - HTTPS endpoint to receive trigger notifications
- Network Access - Manager must be able to reach your 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(
tenantId: "tenant-uuid"
input: {
name: "Payment Processing Service"
username: "payment-service"
hashedKey: "__GENERATE_TOKEN__"
enabled: true
}
) {
generatedToken
machineUser {
id
username
}
}
}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",
"tenant_id": "tenant-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. Re-registering the same backend keeps the existing token and updates the heartbeat timestamp.
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 fired
- trigger_name - Human-readable trigger name
- cron_expression - Cron expression defining the schedule
- run_key - Unique identifier for backend routing
- executed_at - ISO 8601 timestamp of execution
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>"{
"tenants": [
{ "id": "tenant-a-uuid", "name": "Customer A" },
{ "id": "tenant-b-uuid", "name": "Customer B" }
]
}For each returned tenant, resolve its configuration through the tenant-qualified config client (getFrontendConfig(appId, tenantId) / FrontendConfigForTenant) — the same cascade the proxy injects. This keeps one registration and one trigger row per deployment while still doing per-tenant work, replacing the old pattern of N per-tenant registrations and callbacks.
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
Implement comprehensive error handling:
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:
- 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
Verify:
- Machine user token is valid and not expired
- Token has correct format (
Bearer <token>) - Callback URL is a valid HTTPS URL
- Network connectivity from your service to 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 - Comprehensive trigger documentation
- Machine Users - Service account authentication
- REST API - REST endpoint documentation
- API Reference - Complete API reference