# Keep a member's tier or role in sync

When a member's tier, plan or membership changes on your side, the community has to reflect it: the field that records the tier, the roles that grant access, and whatever consumes them. A one-time award is not enough, because access has to be removable as well as grantable.

The API builds and checks the workflow that converges the member's state. The authoritative readback happens through the platform's user and role endpoints.

## The contract to write first

| Part | What to record |
| --- | --- |
| Identity | The stable external identifier, and the community field it maps to |
| Input values | Every external value the workflow accepts, and how it is normalised |
| Canonical field | The field that stores the current tier or plan |
| Role set | The complete set of roles after each value, including roles that must be absent |
| Consumer | The channel, category or feature that must change its answer |
| Timing | How long the change may take, and the point at which it must be exact |

Five claims are separate, and each needs its own readback: the request was accepted, the workflow ran, the field changed, the role set is exactly right, and the consumer now answers differently.

## 1. Create the workflow with the trigger your system uses

A webhook trigger is the usual shape, because the tier change happens on your side:

`Create a workflow`

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Tier sync",
    "trigger": {
      "type": "webhook",
      "name": "Tier changed",
      "options": {
        "tokenExchangeEnabled": false,
        "apiKey": {
          "enabled": true
        }
      }
    }
  }'
```

Keep the generated key from this response; a later read redacts it.

An event ingest trigger works too when the source already batches: use `batchIdPath: "metadata.batch_id"` and a record id such as `change_id`, so a resent change cannot apply twice.

## 2. Converge, do not accumulate

The failure to design out is a role that is added on upgrade and never removed on downgrade. Two rules prevent it:

- **Write the canonical value, do not add to it.** A `post-user-fields` step with `logic: overwrite` stores exactly the new tier. `logic: add` on a tier field makes the number meaningless.
- **Reconcile the whole role set.** Grant the new role and remove the incompatible one in the same run, so the member never holds both.

A platform action that writes a field:

`Add a step`

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows/65f1a2b3c4d5e6f708192a3b/nodes" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --header "If-Match: 162af81f5de4ff2b557a4087de9a9a73a0ed4e621f737787e35a93614cb50d1c" \
  --data '{
    "type": "platform-action",
    "name": "Store current tier",
    "position": {
      "after": "trigger"
    },
    "options": {
      "action": "post-user-fields",
      "selectUser": {
        "type": "email",
        "value": ["{{65f1a2b3c4d5e6f708192a41.email}}"]
      },
      "fields": [
        {
          "label": "Tier",
          "field": "tier",
          "logic": "overwrite",
          "value": ["{{65f1a2b3c4d5e6f708192a41.tier}}"]
        }
      ]
    }
  }'
```

`tier` here is a custom field the community defines, so its key comes from the catalog's `post` list rather than from a guess. The platform checks the key against the community's real fields and keeps the label you send, here `Tier`, because that label is the name the step's result uses. Send the catalog's label, or use your own deliberately and reference that label downstream.

Role reconciliation is a bulk update, because roles are a bulk field. Reversible mode is the wrong tool here: a role change is state, not a reward.

`Add a step`

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows/65f1a2b3c4d5e6f708192a3b/nodes" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --header "If-Match: 68f78253046393414dec404e5442c140bb5db0393a7d1c389ff57bba71585501" \
  --data '{
    "type": "users-bulk-update",
    "name": "Reconcile roles",
    "position": {
      "after": "65f1a2b3c4d5e6f708192a3c"
    },
    "options": {
      "payloadFormat": "csv",
      "processingMode": "standard",
      "identifier": "email",
      "logName": ["Tier sync"],
      "csv": ["{{65f1a2b3c4d5e6f708192a41.rolesCsv}}"]
    }
  }'
```

The CSV itself, with one row per role to add and one per role to remove, comes from a Code step: `type: "code"`, `options.inputs` bound to the tier payload, and the source in `options.sourceCode.code`. Create it with [`POST /{workflowId}/nodes`](https://docs.returning.ai/api-reference/workflow-builder/add-a-step.md) like any other step. A dry run shows the builder's inputs and source but not its output, so live-test the builder on its own against one transition first, then dry-run the rest around it.

## 3. Dry-run the transitions you care about

`Test a whole workflow`

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows/65f1a2b3c4d5e6f708192a3b/test" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "mode": "dry-run",
    "triggerPayload": {
      "email": "trader@example.com",
      "tier": "gold",
      "change_id": "C-1"
    }
  }'
```

Run one dry run per transition in the matrix below, and read `wouldDo` for the field step: the field, the value, and the logic. Nothing is written, so a mistake here costs nothing.

## 4. The transition matrix

| Case | Transition | What it proves |
| --- | --- | --- |
| Create | no member state, then a tier | The initial field and role state are applied |
| Upgrade | lower to higher | The higher access is granted |
| Downgrade | higher to lower | The higher access is removed, not just hidden |
| Jump | low to a non-adjacent high | The workflow does not depend on every intermediate step |
| Revisit | high, low, then high again | A previously held state can be applied again |
| Replay | the same change twice | Delivery retries do not accumulate roles |
| Edge | an unknown tier or a missing identifier | The failure is bounded and does not half-apply |

Deploy the workflow with the API and run the matrix against real trigger calls. A Code step does not change that: the workflow deploys like any other, and the transition matrix is what proves the builder is right.

## 5. Readback that actually proves state

After each transition, read in this order and stop at the first disagreement:

1. [`GET /{workflowId}/runs`](https://docs.returning.ai/api-reference/workflow-builder/list-the-runs-of-a-workflow.md) for the run, then [`GET /runs/{runId}`](https://docs.returning.ai/api-reference/workflow-builder/get-one-run.md) for each step result.
2. The member's fields through the platform user endpoints: the canonical tier value.
3. The member's roles through the platform role endpoints: exact equality with the intended set, not just presence of the expected role.
4. The consumer that uses the role: a channel, a category, or a feature call.

A workflow run that says `success` proves step execution. It does not prove the member's roles, and it does not prove the consumer.

## Failure handling

| What you see | What it means | What to do |
| --- | --- | --- |
| Field updated, role unchanged | The role step was skipped, or wrote the wrong column | Read the run detail for the role step, then the job detail per row. |
| Upgrade works, downgrade leaves the old role | The role step only adds | Reconcile the whole role set, subtract included. |
| Repeated change adds a second role | No deduplication on the sender or the trigger | Configure a record id, or make the sender idempotent. |
| Run `success`, member unchanged | The step wrote the same value it read | Check the binding and the logic. |
| `User not found` | The identifier did not resolve | Fix the sender's field, then the selector binding. |

> A retry re-runs the whole workflow
>
> For a converging workflow that is safe on the field (overwrite) but not on a role addition, so reconcile before retrying.

## Cleanup

Undeploy when the sync should stop; the member's last state stays as it is. A test matrix should use synthetic members, and those can be removed through the platform's user endpoints after the run.

## What the API does not cover

- Writing the role CSV logic for you. A Code step runs what you wrote; the add/remove set and the row order are your design.
- Reading roles and fields. Those are platform endpoints, not workflow ones.
- Deciding the tier rules. A rolling window, a retain period or a one-step demotion is a design decision, and a calculation that needs one usually belongs in a Code step.
- The consumer's own behaviour: a channel or category answering differently is proven by that surface, not by a workflow run.

## Endpoints on this page

- POST [Create a workflow](https://docs.returning.ai/api-reference/workflow-builder/create-a-workflow.md) `/`
- POST [Add a step](https://docs.returning.ai/api-reference/workflow-builder/add-a-step.md) `/{workflowId}/nodes`
- POST [Test a whole workflow](https://docs.returning.ai/api-reference/workflow-builder/test-a-whole-workflow.md) `/{workflowId}/test`
- GET [List the runs of a workflow](https://docs.returning.ai/api-reference/workflow-builder/list-the-runs-of-a-workflow.md) `/{workflowId}/runs`
- GET [Get one run](https://docs.returning.ai/api-reference/workflow-builder/get-one-run.md) `/runs/{runId}`

Paths that start with `/v1/` are full paths; the rest are relative to `https://api.returning.ai/v1/workflow-builder/data-workflows`.
