Skip to content

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:

  1. Machine User - Service account for authentication
  2. Callback Endpoint - HTTPS endpoint to receive trigger notifications
  3. 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:

graphql
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:

bash
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:

json
{
  "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:

javascript
// 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:

json
{
  "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:

bash
curl https://manager.example.com/api/machine/application/<application-id>/tenants \
  -H "Authorization: Bearer <machine-user-token>"
json
{
  "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

javascript
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

python
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

go
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:

javascript
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:

javascript
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:

javascript
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):

javascript
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

javascript
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

javascript
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