Completion-gated provisioning & data handoff

Somewhere in your onboarding there's a task that gates go-live: the signed security review, the completed kickoff form, the approved data mapping. This recipe wires that moment to your own systems: the task completes → you verify it and harvest what the customer entered → you provision (tenant, licenses, environment) → you post a confirmation comment back into the project so everyone sees it happened.

What you'll get

There's no native or Zapier equivalent of this full loop — the harvest and the write-back are what make it a handoff rather than a notification.

Before you start
  • Read the webhook payloads & delivery reference — this recipe leans on its dedupe and fetch-on-event rules.
  • Pick your gating convention: tag the gating tasks (e.g. a gating tag) in your playbook, so the receiver can tell the gate from the hundred other tasks that complete every day.
Step 1 — Subscribe
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{"event_code": "task_completed", "hook_url": "https://your-receiver.example.com/onramp-events"}' \
  "https://api.onramp.us/v1/webhooks"

(Subscribe subtask_completed too if your gate is a single form question rather than a whole task.)

Step 2 — The receiver: verify → harvest → provision → confirm

Never provision from the delivery body. Deliveries aren't signed — a forged POST to your receiver must not be able to create a production tenant. The delivery contributes exactly one thing: the task uuid. Everything you act on comes from your own authenticated API calls. That's the difference between this recipe and what most webhook consumers do today.

from flask import Flask, request

import requests

BASE = "https://api.onramp.us/v1"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
GATING_TAG = "gating"

app = Flask(__name__)
provisioned = set()  # task uuids already handled — your db in production

def fetch(path, **params):
    return requests.get(f"{BASE}{path}", headers=HEADERS, params=params).json()["data"]

@app.post("/onramp-events")
def on_event():
    event = request.get_json(silent=True) or {}
    if event.get("event_name") != "task_completed":
        return "", 204
    task_uuid = ((event.get("payload") or {}).get("task") or {}).get("uuid")
    if not task_uuid or task_uuid in provisioned:
        return "", 204                        # missing uuid or duplicate delivery

    # 1. VERIFY — re-fetch; the API is the truth, the delivery is a doorbell
    task = fetch(f"/tasks/{task_uuid}")
    if task["status"]["name"] != "Finished":  # confirm it's really complete
        return "", 204
    if not any(t["name"] == GATING_TAG for t in task["tags"]):
        return "", 204                        # not a gating task

    # 2. HARVEST — form answers + project data fields
    answers = {
        s["name"]: s["action_response"]
        for s in fetch(f"/tasks/{task_uuid}/subtasks")["subtasks"]
        if s.get("action_response")
    }
    project = fetch(f"/projects/{task['project']['uuid']}")
    fields = {d["name"]: d["value"] for d in project.get("datafield_values") or []}

    # 3. PROVISION — your side (idempotent by task_uuid!)
    result_note = provision(project["name"], answers, fields)  # your function
    provisioned.add(task_uuid)

    # 4. CONFIRM — close the loop where the team can see it
    requests.post(f"{BASE}/tasks/{task_uuid}/comments", headers=HEADERS, json={
        "message_plain": f"✅ Provisioned automatically: {result_note}"
    })
    return "", 200

app.run(port=8080)
import express from "express";

const BASE = "https://api.onramp.us/v1";
const HEADERS = { Authorization: "Bearer YOUR_API_KEY" };
const GATING_TAG = "gating";
const provisioned = new Set();

const fetchData = async (p) =>
  (await (await fetch(BASE + p, { headers: HEADERS })).json()).data;

const app = express().use(express.json());
app.post("/onramp-events", async (req, res) => {
  const event = req.body ?? {};
  if (event.event_name !== "task_completed") return res.sendStatus(204);
  const taskUuid = event.payload?.task?.uuid;
  if (!taskUuid || provisioned.has(taskUuid)) return res.sendStatus(204);

  // 1. VERIFY
  const task = await fetchData(`/tasks/${taskUuid}`);
  if (task.status.name !== "Finished") return res.sendStatus(204);
  if (!task.tags.some((t) => t.name === GATING_TAG)) return res.sendStatus(204);

  // 2. HARVEST
  const subtasks = (await fetchData(`/tasks/${taskUuid}/subtasks`)).subtasks;
  const answers = Object.fromEntries(
    subtasks.filter((s) => s.action_response).map((s) => [s.name, s.action_response]),
  );
  const project = await fetchData(`/projects/${task.project.uuid}`);
  const fields = Object.fromEntries(
    (project.datafield_values ?? []).map((d) => [d.name, d.value]),
  );

  // 3. PROVISION (idempotent by taskUuid)
  const resultNote = await provision(project.name, answers, fields); // yours
  provisioned.add(taskUuid);

  // 4. CONFIRM
  await fetch(`${BASE}/tasks/${taskUuid}/comments`, {
    method: "POST",
    headers: { ...HEADERS, "Content-Type": "application/json" },
    body: JSON.stringify({ message_plain: `✅ Provisioned automatically: ${resultNote}` }),
  });
  res.sendStatus(200);
});
app.listen(8080);
# The four API calls in the loop, individually:

# 1. Verify: the task's real status + tags
curl -H "Authorization: Bearer YOUR_API_KEY" "https://api.onramp.us/v1/tasks/TASK_UUID"

# 2a. Harvest form answers (action_response on each subtask)
curl -H "Authorization: Bearer YOUR_API_KEY" "https://api.onramp.us/v1/tasks/TASK_UUID/subtasks"

# 2b. Harvest project data fields
curl -H "Authorization: Bearer YOUR_API_KEY" "https://api.onramp.us/v1/projects/PROJECT_UUID"

# 4. Confirm back into the project
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{"message_plain": "✅ Provisioned automatically: tenant acme-prod created"}' \
  "https://api.onramp.us/v1/tasks/TASK_UUID/comments"
Step 3 — The safety net (not optional)

A missed delivery here isn't a missed Slack ping — it's a customer whose go-live silently stalls. Run a reconciliation poll (hourly is plenty):

# every hour: any finished gating task we somehow haven't provisioned?
for t in pages("/tasks", "tasks", tag_uuid=GATING_TAG_UUID, status_code="TASK_FINISHED"):
    if t["uuid"] not in provisioned:
        handle(t["uuid"])   # same code path as the webhook

Same handler both ways, so the webhook is just the fast path.

Low-code appendix (Zapier / n8n / Workato)

Most teams consuming task_completed today do it through low-code tools — this recipe works there too, with the same rules:

  • Trigger: "Webhooks by Zapier" (Catch Hook) / n8n Webhook node / Workato webhook trigger. Use the caught hook URL as hook_url when subscribing.
  • Then re-fetch: add an HTTP step calling GET /v1/tasks/{{uuid}} (and /subtasks) with your API key — don't map fields straight off the trigger body. The trigger gives you the uuid; the HTTP step gives you the truth.
  • Filter: continue only if the fetched task's tags contain gating and status is Finished.
  • Dedupe: Zapier Storage / n8n staticData keyed by task uuid, so a duplicate delivery can't provision twice.
  • Confirm: final HTTP step POSTs the comment back.
Good to know
  • Idempotency is the whole game. Deliveries repeat, reconciliation overlaps the webhook path, humans re-complete tasks. Key every provision on the task uuid and make re-runs no-ops.
  • action_response is a string — a date answer arrives as text; parse deliberately.
  • The confirmation comment fires comment_new. If you also run the comment alerts recipe, its echo-loop filter (drop your own created_by_uuid) keeps your own confirmations out of your alert channel.
  • UUID-only — integer IDs leave the API July 31, 2026.