Skip to content

Trigger Parameters & Payload Shapes

Understanding what data your function receives — and where it comes from — is critical to writing correct compute functions. This page documents every parameter shape for every trigger type.

The Two Globals

Every compute function has access to two global objects:

Global What It Contains Who Sets It
triggerParams Static configuration from the trigger definition You, when creating/updating the trigger
executionParams Runtime data that changes per execution The system or the caller, depending on trigger type
async function run() {
  // Static config — same every time this trigger fires
  const apiKey = triggerParams.apiKey;

  // Runtime data — different every execution
  const recordId = executionParams.recordId;

  return { success: true };
}

Common Mistake

The #1 mistake is looking for runtime data in triggerParams. If your function receives undefined for a value you expect, check whether you're reading from the wrong global.


Where triggerParams Comes From

triggerParams always comes from triggerMetadata.params in the trigger definition. This is the same regardless of trigger type.

When you create a trigger:

curl -X POST "$CENTRALI_URL/data/workspace/$WORKSPACE/api/v1/function-triggers" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-trigger",
    "functionId": "YOUR_FUNCTION_ID",
    "executionType": "on-demand",
    "triggerMetadata": {
      "params": {
        "webhookUrl": "https://hooks.slack.com/services/...",
        "apiKey": {
          "value": "sk_live_abc123",
          "encrypt": true
        },
        "batchSize": 100
      }
    }
  }'

In your function, those values appear as:

async function run() {
  triggerParams.webhookUrl  // "https://hooks.slack.com/services/..."
  triggerParams.apiKey      // "sk_live_abc123" (auto-decrypted)
  triggerParams.batchSize   // 100

  return { success: true };
}

triggerParams is always the same every time this trigger fires. It only changes if you update the trigger definition.


Where executionParams Comes From

This is where it gets interesting. The shape of executionParams depends entirely on the trigger type.

Trigger Type executionParams Source Shape
Event-driven The record event (system-generated) Varies by event type — see below
On-demand The request body you send to /execute Whatever you pass
HTTP The incoming HTTP request body Whatever the caller sends
Scheduled Nothing (no caller) {} empty object

Event-Driven: executionParams by Event Type

Event-driven triggers are the most complex because the shape of executionParams changes depending on the event type. The critical difference is whether data contains a single record or a { before, after } pair.

Quick Reference

Event data Shape User Field
record_created Full record object createdBy
record_updated { before, after } — two full records updatedBy
record_deleted Full record object (as it was before deletion) deletedBy
record_restored { before, after } — two full records restoredBy
record_reverted { before, after } — two full records revertedBy
records_bulk_created No data field — uses recordIds array instead createdBy

record_created

data is the full record.

// executionParams shape:
{
  event: "record_created",
  workspaceSlug: "acme-corp",
  recordSlug: "orders",              // structure slug
  recordId: "550e8400-e29b-...",
  data: {                            // ← the full record
    id: "550e8400-e29b-...",
    workspaceSlug: "acme-corp",
    recordSlug: "orders",
    data: {                          // ← your actual field values
      orderNumber: "ORD-2026-001",
      customerId: "cust_abc123",
      status: "pending",
      total: 109.97
    },
    status: "active",
    version: 1,                      // always 1 for new records
    createdAt: "2026-01-15T10:30:00.000Z",
    updatedAt: "2026-01-15T10:30:00.000Z",
    createdBy: "user_xyz789",
    updatedBy: "user_xyz789"
  },
  timestamp: "2026-01-15T10:30:00.123Z",
  createdBy: "user_xyz789"
}

Example function:

async function run() {
  const { recordId, recordSlug, data } = executionParams;

  // Access your field values through data.data
  const orderNumber = data.data.orderNumber;   // "ORD-2026-001"
  const total = data.data.total;               // 109.97
  const customerId = data.data.customerId;     // "cust_abc123"

  // Access record metadata
  const version = data.version;                // 1
  const createdAt = data.createdAt;            // "2026-01-15T10:30:00.000Z"

  // Send notification using triggerParams for config
  await api.httpPost(triggerParams.slackWebhookUrl, {
    text: `New order ${orderNumber} for $${total}`
  });

  return { success: true, orderNumber };
}

Why data.data?

The outer data is the full record object (with id, version, status, etc.). Your actual field values live inside data.data. This is consistent with how records are structured throughout the Centrali API.


record_updated

data is { before, after } — two full record snapshots.

This is the most powerful event type because you can compare previous and current state to detect exactly what changed.

// executionParams shape:
{
  event: "record_updated",
  workspaceSlug: "acme-corp",
  recordSlug: "orders",
  recordId: "550e8400-e29b-...",
  data: {
    before: {                        // ← record BEFORE the update
      id: "550e8400-e29b-...",
      workspaceSlug: "acme-corp",
      recordSlug: "orders",
      data: {
        orderNumber: "ORD-2026-001",
        status: "pending",
        total: 109.97
      },
      status: "active",
      version: 1,
      createdAt: "2026-01-15T10:30:00.000Z",
      updatedAt: "2026-01-15T10:30:00.000Z",
      createdBy: "user_xyz789",
      updatedBy: "user_xyz789"
    },
    after: {                         // ← record AFTER the update
      id: "550e8400-e29b-...",
      workspaceSlug: "acme-corp",
      recordSlug: "orders",
      data: {
        orderNumber: "ORD-2026-001",
        status: "shipped",           // ← changed
        total: 109.97,
        trackingNumber: "1Z999AA1"   // ← new field
      },
      status: "active",
      version: 2,                    // ← incremented
      createdAt: "2026-01-15T10:30:00.000Z",
      updatedAt: "2026-01-15T14:45:00.000Z",  // ← updated
      createdBy: "user_xyz789",
      updatedBy: "user_admin001"     // ← who made the update
    }
  },
  timestamp: "2026-01-15T14:45:00.456Z",
  updatedBy: "user_admin001"
}

Example function — detecting a status change:

async function run() {
  const { recordId, data } = executionParams;
  const { before, after } = data;

  const oldStatus = before.data.status;   // "pending"
  const newStatus = after.data.status;    // "shipped"

  if (oldStatus === newStatus) {
    // Status didn't change — skip
    return { success: true, skipped: true };
  }

  api.log({
    message: "Order status changed",
    orderId: recordId,
    from: oldStatus,
    to: newStatus
  });

  // Only notify when shipped
  if (newStatus === "shipped") {
    await api.httpPost(triggerParams.notificationEndpoint, {
      type: "order_shipped",
      orderId: recordId,
      trackingNumber: after.data.trackingNumber,
      customerEmail: after.data.customerEmail
    }, {
      headers: { "Authorization": `Bearer ${triggerParams.notificationApiKey}` }
    });
  }

  return { success: true, statusChanged: true };
}

Example function — finding all changed fields:

async function run() {
  const { data } = executionParams;
  const { before, after } = data;

  const changedFields = [];
  for (const key in after.data) {
    if (JSON.stringify(before.data[key]) !== JSON.stringify(after.data[key])) {
      changedFields.push({
        field: key,
        from: before.data[key],
        to: after.data[key]
      });
    }
  }

  api.log({ message: "Fields changed", changes: changedFields });

  return { success: true, changedFields };
}

record_deleted

data is the full record as it was before deletion. This is your last chance to read the record's data — once hard-deleted, it's gone.

// executionParams shape:
{
  event: "record_deleted",
  workspaceSlug: "acme-corp",
  recordSlug: "orders",
  recordId: "550e8400-e29b-...",
  data: {                            // ← full record before deletion
    id: "550e8400-e29b-...",
    workspaceSlug: "acme-corp",
    recordSlug: "orders",
    data: {
      orderNumber: "ORD-2026-001",
      status: "cancelled",
      total: 109.97,
      cancellationReason: "Customer requested"
    },
    status: "active",
    version: 3,
    createdAt: "2026-01-15T10:30:00.000Z",
    updatedAt: "2026-01-15T16:00:00.000Z",
    createdBy: "user_xyz789",
    updatedBy: "user_admin001"
  },
  timestamp: "2026-01-15T16:30:00.789Z",
  deletedBy: "user_admin001"           // ← note: deletedBy, not updatedBy
}

Example function — archiving before deletion:

async function run() {
  const { recordId, data, deletedBy } = executionParams;

  // Archive to external system before it's gone
  await api.httpPost(triggerParams.archiveEndpoint, {
    originalId: recordId,
    orderNumber: data.data.orderNumber,
    archivedData: data.data,
    deletedBy,
    deletedAt: executionParams.timestamp
  }, {
    headers: { "X-API-Key": triggerParams.archiveApiKey }
  });

  api.log({
    message: "Order archived after deletion",
    orderNumber: data.data.orderNumber
  });

  return { success: true };
}

record_restored

data is { before, after }. before is the archived/deleted state, after is the restored active state.

// executionParams shape:
{
  event: "record_restored",
  workspaceSlug: "acme-corp",
  recordSlug: "orders",
  recordId: "550e8400-e29b-...",
  data: {
    before: {
      // ... full record in archived/deleted state
      status: "archived",            // ← was archived
      version: 3
    },
    after: {
      // ... full record after restoration
      status: "active",              // ← now active again
      version: 4                     // ← version incremented
    }
  },
  timestamp: "2026-01-16T09:00:00.000Z",
  restoredBy: "user_admin001"          // ← note: restoredBy
}

Example function:

async function run() {
  const { recordId, data, restoredBy } = executionParams;

  // Re-sync restored record with external system
  await api.httpPost(triggerParams.syncEndpoint, {
    action: "restore",
    recordId,
    data: data.after.data
  });

  api.log({ message: "Record restored and re-synced", recordId, restoredBy });

  return { success: true };
}

record_reverted

data is { before, after }. Similar to record_updated, but also includes revertedToVersion.

// executionParams shape:
{
  event: "record_reverted",
  workspaceSlug: "acme-corp",
  recordSlug: "orders",
  recordId: "550e8400-e29b-...",
  data: {
    before: {
      // ... record before revert (latest version)
      data: { status: "shipped", total: 109.97 },
      version: 5
    },
    after: {
      // ... record after revert (content from older version, new version number)
      data: { status: "pending", total: 109.97 },
      version: 6                     // ← new version, NOT the old version number
    }
  },
  timestamp: "2026-01-16T10:00:00.000Z",
  revertedBy: "user_admin001",
  revertedToVersion: 2                 // ← which version's data was restored
}

Example function:

async function run() {
  const { recordId, data, revertedBy, revertedToVersion } = executionParams;

  api.log({
    message: "Record reverted",
    recordId,
    revertedBy,
    revertedToVersion,
    fromVersion: data.before.version,
    toVersion: data.after.version
  });

  return { success: true };
}

records_bulk_created

Different from all other events. There is no data field. Instead, you get recordIds — an array of UUIDs. This is for performance reasons; bulk operations can create thousands of records.

// executionParams shape:
{
  event: "records_bulk_created",
  workspaceSlug: "acme-corp",
  recordSlug: "orders",
  structureId: "123e4567-e89b-...",
  recordIds: [                       // ← array of IDs, NOT full records
    "550e8400-e29b-41d4-a716-446655440001",
    "550e8400-e29b-41d4-a716-446655440002",
    "550e8400-e29b-41d4-a716-446655440003"
  ],
  count: 3,
  timestamp: "2026-01-15T10:30:00.123Z",
  createdBy: "user_xyz789",
  schemaDiscoveryMode: "strict"
}

Example function:

async function run() {
  const { recordIds, count, recordSlug } = executionParams;

  api.log({ message: `Bulk created ${count} ${recordSlug} records` });

  // Fetch full records if you need the data (be mindful of large batches)
  if (count <= 100) {
    for (const id of recordIds) {
      const record = await api.fetchRecord(id);
      // ... process each record
    }
  }

  return { success: true, processedCount: count };
}

No data field

Unlike record_created, bulk events do NOT include the full record data. You must use api.fetchRecord() if you need field values. Individual record_created events are NOT fired for bulk operations.


Failed Events

Failed events fire when a record operation fails. They have a simpler shape with an error string.

// record_created_failed
{
  event: "record_created_failed",
  workspaceSlug: "acme-corp",
  recordSlug: "orders",
  data: { /* the data that was attempted */ },
  timestamp: "2026-01-15T10:30:00.000Z",
  createdBy: "user_xyz789",
  error: "Validation failed: 'total' must be a number"
}

// record_updated_failed
{
  event: "record_updated_failed",
  workspaceSlug: "acme-corp",
  recordSlug: "orders",
  recordId: "550e8400-e29b-...",
  data: { /* the update data that was attempted */ },
  timestamp: "2026-01-15T10:30:00.000Z",
  updatedBy: "user_xyz789",
  error: "Record not found"
}

// record_deleted_failed
{
  event: "record_deleted_failed",
  workspaceSlug: "acme-corp",
  recordSlug: "orders",
  recordId: "550e8400-e29b-...",
  timestamp: "2026-01-15T10:30:00.000Z",
  deletedBy: "user_xyz789",
  error: "Insufficient permissions"
}

Example function — alert on failures:

async function run() {
  const { event, recordSlug, error, timestamp } = executionParams;

  await api.httpPost(triggerParams.alertWebhook, {
    severity: "warning",
    message: `${event} on ${recordSlug}: ${error}`,
    timestamp
  });

  return { success: true };
}

On-Demand: executionParams Shape

For on-demand triggers, the request body you send to /execute becomes executionParams directly. There is no wrapping — your JSON body IS executionParams.

What You Send

curl -X POST "$CENTRALI_URL/data/workspace/$WORKSPACE/api/v1/function-triggers/TRIGGER_ID/execute" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "orderId": "550e8400-e29b-...",
    "action": "recalculate",
    "dryRun": false
  }'

What Your Function Receives

// executionParams is exactly your request body:
{
  orderId: "550e8400-e29b-...",
  action: "recalculate",
  dryRun: false
}

Example Function

async function run() {
  // Runtime data from the /execute call
  const orderId = executionParams.orderId;     // "550e8400-e29b-..."
  const action = executionParams.action;       // "recalculate"
  const dryRun = executionParams.dryRun;       // false

  // Static config from trigger definition
  const batchSize = triggerParams.batchSize;   // 1000 (set at trigger creation)
  const apiKey = triggerParams.apiKey;         // "sk_live_..." (auto-decrypted)

  if (!orderId) {
    return { success: false, error: "orderId is required" };
  }

  const order = await api.fetchRecord(orderId);

  if (dryRun) {
    api.log({ message: "Dry run — no changes made", orderId });
    return { success: true, dryRun: true, order: order.data };
  }

  // ... perform actual work
  return { success: true };
}

SDK Equivalent

const result = await centrali.invokeFunction('my-trigger-name', {
  orderId: '550e8400-e29b-...',
  action: 'recalculate',
  dryRun: false
});
// The object you pass becomes executionParams in the function

You define the shape

Unlike event-driven triggers where the system defines the shape, on-demand executionParams is whatever you decide to send. Document your expected shape clearly for anyone calling your function.


HTTP Trigger: executionParams Shape

For HTTP triggers, the incoming HTTP request body is passed as executionParams. This is what the external service (Stripe, GitHub, etc.) sends to your trigger URL.

What the External Service Sends

# Stripe sends a webhook to your HTTP trigger URL:
POST /data/workspace/acme/api/v1/http-trigger/payments/stripe

{
  "id": "evt_1234567890",
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_abc123",
      "amount": 10997,
      "currency": "usd",
      "customer": "cus_xyz789"
    }
  }
}

What Your Function Receives

// executionParams is the request body:
{
  id: "evt_1234567890",
  type: "payment_intent.succeeded",
  data: {
    object: {
      id: "pi_abc123",
      amount: 10997,
      currency: "usd",
      customer: "cus_xyz789"
    }
  }
}

Example Function — Stripe Webhook

async function run() {
  const event = executionParams;

  api.log({ message: "Stripe webhook received", type: event.type });

  switch (event.type) {
    case "payment_intent.succeeded": {
      const payment = event.data.object;

      // Find the order by Stripe payment ID
      const orders = await api.queryRecords("orders", {
        filter: { "data.stripePaymentId": payment.id }
      });

      if (orders.data.length > 0) {
        await api.updateRecord(orders.data[0].id, {
          paymentStatus: "paid",
          paidAmount: payment.amount / 100,
          paidAt: new Date().toISOString()
        });
      }
      break;
    }

    case "charge.refunded": {
      const refund = event.data.object;
      // ... handle refund
      break;
    }

    default:
      api.log({ message: "Unhandled event type", type: event.type });
  }

  return { success: true };
}

Example Function — GitHub Webhook

async function run() {
  const payload = executionParams;

  // Use the signing secret from triggerParams to verify
  // (configured as encrypted param on trigger)
  const secret = triggerParams.githubWebhookSecret;

  if (payload.action === "opened" && payload.pull_request) {
    const pr = payload.pull_request;

    await api.createRecord("pull-requests", {
      number: pr.number,
      title: pr.title,
      author: pr.user.login,
      repo: payload.repository.full_name,
      url: pr.html_url,
      status: "open",
      createdAt: pr.created_at
    });

    api.log({ message: "PR tracked", number: pr.number });
  }

  return { success: true };
}

Scheduled Trigger: executionParams Shape

Scheduled triggers have no caller, so executionParams is an empty object. All your configuration should be in triggerParams.

What Your Function Receives

// executionParams for scheduled triggers:
{}    // empty — there's nobody to pass runtime data

Example Function

async function run() {
  // No executionParams to read — use triggerParams for everything
  const reportType = triggerParams.reportType;     // "daily_sales"
  const recipients = triggerParams.recipients;     // ["sales@acme.com"]
  const emailApiKey = triggerParams.emailApiKey;   // auto-decrypted

  // Generate report
  const yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);

  const sales = await api.aggregateRecords("orders", {
    dateWindow: {
      field: "createdAt",
      from: yesterday.toISOString(),
      to: new Date().toISOString()
    },
    operations: {
      totalRevenue: { sum: "data.total" },
      orderCount: { count: "*" }
    }
  });

  // Send report email
  for (const recipient of recipients) {
    await api.httpPost("https://api.resend.com/emails", {
      from: "reports@acme.com",
      to: recipient,
      subject: `Daily Sales Report — ${api.formatDate(yesterday, "MMMM D, YYYY")}`,
      html: `<p>Orders: ${sales[0].orderCount}</p><p>Revenue: $${sales[0].totalRevenue.toFixed(2)}</p>`
    }, {
      headers: { "Authorization": `Bearer ${emailApiKey}` }
    });
  }

  return { success: true, orderCount: sales[0].orderCount };
}

Put everything in triggerParams

Since scheduled triggers have no executionParams, store all configuration (endpoints, API keys, filters, recipients) in the trigger's triggerMetadata.params.


Full Comparison: All Trigger Types Side by Side

Event-Driven On-Demand HTTP Scheduled
triggerParams source triggerMetadata.params triggerMetadata.params triggerMetadata.params triggerMetadata.params
executionParams source System (event payload) Your /execute request body External HTTP request body Empty {}
executionParams shape Varies by event type Whatever you send Whatever the caller sends {}
Has event field? Yes No No No
Has recordId? Yes (except bulk) Only if you send it Only if caller sends it No
Has data? Yes — record or {before, after} Only if you send it Only if caller sends it No

Pattern: One Function, Multiple Trigger Types

When the same function is attached to multiple trigger types, use executionParams to detect which type fired:

async function run() {
  // Event-driven triggers always have an 'event' field
  if (executionParams.event) {
    return await handleEvent(executionParams);
  }

  // On-demand triggers will have whatever you defined
  if (executionParams.action) {
    return await handleManual(executionParams);
  }

  // Scheduled triggers have empty executionParams
  if (Object.keys(executionParams).length === 0) {
    return await handleScheduled();
  }

  // HTTP triggers — check for external webhook signatures
  if (executionParams.type && executionParams.data?.object) {
    return await handleWebhook(executionParams);
  }

  return { success: false, error: "Unknown trigger source" };
}

async function handleEvent(params) {
  const { event, recordId, data } = params;

  if (event === "record_created") {
    // data is the full record
    api.log({ message: "New record", id: recordId, fields: data.data });
  } else if (event === "record_updated") {
    // data is { before, after }
    api.log({ message: "Updated", from: data.before.version, to: data.after.version });
  } else if (event === "record_deleted") {
    // data is the full record before deletion
    api.log({ message: "Deleted", id: recordId, lastState: data.data });
  }

  return { success: true, source: "event", event };
}

async function handleManual(params) {
  api.log({ message: "Manual execution", action: params.action });
  return { success: true, source: "on-demand" };
}

async function handleScheduled() {
  api.log({ message: "Scheduled execution" });
  return { success: true, source: "scheduled" };
}

async function handleWebhook(payload) {
  api.log({ message: "Webhook received", type: payload.type });
  return { success: true, source: "http" };
}

Common Mistakes

Mistake 1: Accessing data.data on an update event

// ❌ WRONG — record_updated has { before, after }, not a direct record
const name = executionParams.data.data.name;  // TypeError!

// ✅ CORRECT
const name = executionParams.data.after.data.name;

Mistake 2: Expecting data on bulk events

// ❌ WRONG — bulk events don't have a data field
const records = executionParams.data;  // undefined!

// ✅ CORRECT — use recordIds and fetch individually
const ids = executionParams.recordIds;
for (const id of ids) {
  const record = await api.fetchRecord(id);
}

Mistake 3: Expecting executionParams on scheduled triggers

// ❌ WRONG — scheduled triggers have empty executionParams
const reportType = executionParams.reportType;  // undefined!

// ✅ CORRECT — use triggerParams for scheduled trigger config
const reportType = triggerParams.reportType;

Mistake 4: Putting runtime data in triggerParams

// ❌ WRONG — triggerParams is static, set at trigger creation
// You can't change it per execution
const orderId = triggerParams.orderId;  // This is the same every time!

// ✅ CORRECT — use executionParams for per-execution data
const orderId = executionParams.orderId;

Mistake 5: Forgetting that deletedBy / restoredBy are top-level

// ❌ WRONG — looking for the user in the wrong place
const who = executionParams.data.deletedBy;   // undefined

// ✅ CORRECT — it's a top-level field on executionParams
const who = executionParams.deletedBy;        // "user_admin001"