Skip to content

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:

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

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",
  "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:

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: the schedule
  • run_key: the operator-chosen routing key for the trigger
  • executed_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:

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

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 ​

Handle errors:

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 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 of Bearer <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 refuses
  • 404 Application not found or does not belong to your project - the application_id is not in the machine user's environment's project
  • 429 - too many failed authentications; wait for Retry-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 ​