Multi-step quests
A participant finishes several steps and is paid once. What your app sends, what AdQuest decides, and how to read progress.
On this page
The picture in one minute
A multi-step quest is one quest with a checklist. The participant ticks off the steps, and AdQuest pays one reward after the last required step is verified. Think of it as a boarding pass: each stamp is a step, and the reward is the flight.
| Idea | What it means for you |
|---|---|
| One reward | The quest pays once. A step never pays, so you never grant anything per step. |
| The server decides | Your app can say "start" or "click". It can never say "done". AdQuest marks a step verified. |
| Each participant has an execution | Their personal run of the quest. It records which steps are verified and which steps are still open. |
| Steps have a version | If the owner edits the steps, new participants get the new version. People already running keep the old one. |
Before you start
- Participant sessions working, so you have a participant token for each user. See the participant sessions guide.
- Multi-step quests are enabled for your environment. If they are not, the API answers 404 MULTI_STEP_DISABLED and the quest is not served.
- The quest belongs to your own platform and is active. Steps that depend on your own data (condition, custom, streak) work on private quests only.
- Your client declares the multi_step capability. The AdQuest SDK does this for you. A raw HTTP client must send the X-AdQuest-Runtime header shown below, or the quest is hidden from it.
- Your Event signing secret, if any step is proven by your backend. It stays on your server.
Who proves each step?
Find the step in the left column. The other columns say who proves it and what you have to build.
| Step type | Participant does | Proven by | You build |
|---|---|---|---|
| event | Does something in your product | Your backend | Send a signed server event |
| condition | Reaches a target, such as 3 orders or 5000 spent | Your backend | Register the activity, then send signed events |
| custom → sdk_event | Triggers an app event | Your backend | Send a signed server event. Events from the SDK only show progress. |
| custom → url_callback | Does something your system can confirm | Your backend | An HTTPS endpoint that answers verified: true |
| custom → host_verified | Gets a flag from your app | Your app | Grant the capability in the host context |
| custom → manual_review | Submits proof | A reviewer | Nothing. Staff approve or reject. |
| streak | Returns on consecutive days | Your backend | Call the callback URL once per day |
| video, banner, survey, visit | Watches, reads, answers or visits | AdQuest | Call start, click or send answers. AdQuest measures time on its own. |
| profile, referral | Saves a profile or invites a friend | AdQuest | Nothing extra |
What happens to a participant
| Stage | What happens | Step status |
|---|---|---|
| 1. Start | AdQuest reserves a slot and the budget, and binds the steps version to the participant. | All steps pending |
| 2. Steps | Each step moves on its own as proof arrives. | pending → verified (instant proof), pending → in_review → verified (a reviewer decides), in_review → rejected → in_review (resubmit, limited attempts) |
| 3. Admission | Every required step is verified. AdQuest admits the execution. | steps_complete is true |
| 4. Settlement | The reward is paid once. | settlement_status: pending → success |
- A verified step stays verified. Sending proof for it again changes nothing and answers 200.
- Sequential quests unlock a step only after every required step before it is verified. Unordered quests unlock all steps at once.
- An optional step never blocks the reward.
- Approval does not need the participant to be online. If a reviewer approves late, AdQuest takes a new hold under the same capacity and budget rules, and retries every minute if the quest is paused or full. Nothing is paid without a hold.
Walkthrough: place 2 orders, then verify your profile
This example uses a sequential quest with two steps: a condition step called orders (count 2 distinct orders) and a profile step called profile. Follow the numbers.
Register the activity once per platform (setup, not per participant).
Start the quest and read the execution. You need execution_id and steps_version from it.
When your backend sees an order, send a signed event for the orders step.
Read the execution again to show progress. Repeat until the step says verified.
The profile step unlocks. Once it is verified, AdQuest settles the quest.
curl -X POST https://api.adquest.batriders.ir/api/v1/platforms/PLATFORM_ID/event-definitions \
-H "Authorization: Bearer YOUR_DASHBOARD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"order_placed","label":"Order placed","has_entity":true,"has_amount":false}'import { createAdQuestClient } from "@adquest/sdk";
const client = createAdQuestClient({ baseUrl, apiKey, participantToken, externalUserId });
await client.startQuest(23, { requiresTimer: false });
const execution = await client.getQuestExecution(23);
// execution.execution_id, execution.steps_version, execution.steps[].statuscurl "https://api.adquest.batriders.ir/api/v1/platform/quests/23/execution?external_user_id=user_123" \
-H "X-AdQuest-Platform-Key: YOUR_PUBLIC_API_KEY" \
-H "X-AdQuest-Participant-Token: TOKEN_FOR_THIS_USER" \
-H 'X-AdQuest-Runtime: {"runtime_version":1,"capabilities":["multi_step"]}'import { createHmac } from "node:crypto";
// executionId and stepsVersion come from the execution you just read.
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = createHmac("sha256", eventSigningSecret)
.update(["v3", eventId, "order_placed", "23", externalUserId, timestamp, "orders", String(stepsVersion), String(executionId), orderId, "", "", ""].join("."))
.digest("hex");
await questRequest(config, 23, "events", {
event_id: eventId, event_name: "order_placed", source: "server", timestamp, signature,
step_key: "orders", entity_id: orderId,
});The response for an accepted event says whether it counted. A duplicate delivery returns the same result and does not count twice.
{
"quest_id": 23,
"execution_id": 812,
"steps_version": 2,
"ordering": "sequential",
"state": "reserved",
"expires_at": "2026-11-09T15:30:00+00:00",
"steps": [
{ "step_key": "orders", "format": "condition", "status": "pending", "available": true,
"progress": { "current": 1, "target": 2, "unit": "items", "satisfied": false } },
{ "step_key": "profile", "format": "profile", "status": "pending", "available": false }
],
"steps_complete": false,
"settlement_status": "none"
}Condition steps in detail
A condition step reads: metric of an activity, operator, target. For example, count of order_placed is at least 3.
| Metric | Counts | Event must carry |
|---|---|---|
| occurrence | Whether it happened once | Nothing extra |
| count | Number of events | Nothing extra |
| count_distinct | Different entity_id values, so one order counts once even if delivered twice | entity_id (the activity is registered with has_entity) |
| sum | Total of amount_minor, in one currency | amount_minor as an integer in minor units, and currency (has_amount) |
| host_state | A flag your app granted is currently granted | Nothing. It reads the host context, no events. |
- Operators: gte, gt, eq, lte, lt. There are no expressions and no AND/OR inside one step. Use two steps for two conditions.
- Register every activity first (the call in the walkthrough). An unregistered event_name is refused with EVENT_NOT_REGISTERED.
- Only activity that happens after the participant starts counts. In a sequential quest, only after that step unlocks.
- Late events are accepted for up to 7 days, in any order. An event more than 60 seconds in the future is refused.
- Amounts are integers in minor units (cents). 5000 with currency USD is 50.00 USD.
Condition events need the v3 signature. It is the v2 signature plus four fields: entity_id, amount_minor, currency and reverses_event_id. Use an empty string for any field the event does not have, as in the walkthrough.
const canonical = [
"v3", eventId, eventName, String(questId), externalUserId, timestamp,
stepKey, String(stepsVersion), String(executionId),
entityId, amountMinor, currency, reversesEventId, // "" when the event has none
].join(".");
// signature = lowercase hex HMAC-SHA256(canonical) with your Event signing secretcurl -X POST https://api.adquest.batriders.ir/api/v1/platforms/PLATFORM_ID/event-definitions \
-H "Authorization: Bearer YOUR_DASHBOARD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"order_refunded","label":"Order refunded","has_entity":true,"reverses":"order_placed"}'- Send the refund to the same step with event_name order_refunded and reverses_event_id set to the original event_id.
- The original event stops counting. This works even if the refund arrives before the original.
- Before the quest is admitted, a refund can move a verified step back to pending. After admission, a refund changes nothing and the reward is not taken back.
Reading the state
Poll the execution, or read one step. Use these fields to drive your screen.
| Field | Use it to |
|---|---|
| steps[].status | Show pending, in_review, verified or rejected. |
| steps[].available | Grey out locked steps in a sequential quest. |
| steps[].progress | Show "2 / 3 items" for a condition step. |
| steps[].elapsed_seconds | Resume a video or timer countdown after the widget reopens. The server measures it. |
| steps[].review | Explain a rejection and whether the participant may resubmit. |
| state and expires_at | Show how long the reservation is held. |
| steps_complete | True when every required step is verified. |
| settlement_status | none, pending or success. Credit your own app only on success. |
| unsettled_reason | Why a complete execution is not paid yet, such as a paused quest. AdQuest keeps retrying. |
SDK calls you will use
| Goal | SDK method |
|---|---|
| Start the quest | startQuest(questId) |
| Read all steps | getQuestExecution(questId) |
| Read one step | getQuestStep(questId, stepKey) |
| Start, click, answer or send proof for a step | submitQuestStepEvidence(questId, stepKey, input) |
| Finish the quest | A state read. It returns 409 MULTI_STEP_REQUIRED until the required steps are verified. It never verifies anything. |
Errors and what to do
| Code | Meaning | What to do |
|---|---|---|
| MULTI_STEP_DISABLED | The feature is off, or the client did not declare multi_step | Send the runtime header, or ask AdQuest to enable it |
| NOT_STARTED | The participant has no execution | Start the quest first |
| STEP_NOT_FOUND | step_key is not in the participant's version | Read the execution and use its keys |
| STEP_OUT_OF_ORDER, STEP_LOCKED | An earlier required step is not verified yet | Show the step as locked until available is true |
| STEP_NOT_STARTED | A timed step was submitted before start | Send action start, wait, then submit |
| MULTI_STEP_REQUIRED | Complete was called too early | Keep showing the steps. Do not retry in a loop. |
| EVENT_NOT_REGISTERED | The activity is not registered for the platform | Register it, then publish the step |
| INVALID_EVENT_SIGNATURE | The HMAC does not match | Check the exact string, the order of fields and the secret |
| EVENT_ID_REUSED | That event_id already proved another step or execution | Use a new event_id for each real event |
| EVENT_WINDOW_EXPIRED | The event is older than 7 days or in the future | Check your clock and the event time |
| EVENT_NAME_MISMATCH | The event is not the step's activity or its reversal | Send the name the step uses |
| ENTITY_ID_REQUIRED, INVALID_AMOUNT, CURRENCY_MISMATCH | A required attribute is missing or wrong | Send entity_id, an integer amount_minor and the registered currency |
| EXECUTION_NOT_STARTED | The execution or its version is not the one in the event | Read the execution again and sign with its ids |
| STEP_CALLBACK_ONLY | You sent evidence to a streak step | Streaks are advanced by the daily callback only |
| TIMING_ADMIN_ONLY | An owner set answer, gap or attempt time on a type that does not allow it | Only custom and multi-step quests accept an owner attempt time (15 minutes to 30 days). Leave timing to AdQuest elsewhere |
| RESERVATION_EXPIRED, QUEST_PROCESSING, QUEST_ALREADY_COMPLETED | The hold ended, is settling or is already paid | Read the execution and show the real state |
Step links, attempt time and checking your integration
A step can carry a link to your app (https or an app scheme such as myapp://orders). The SDK only opens it; your app still has to prove the step. Read it as config.action_url and config.action_label on the step.
Multi-step and custom quests can have an attempt time of 15 minutes to 30 days. Leave it empty for no deadline. After it passes, late evidence is refused and the participant starts a fresh attempt.
In the dashboard, the integration panel shows when each step last received a verified event from your backend, and a Test callback button sends one signed request to your url_callback endpoint so you can see whether it answers.
Mistakes people make
- Signing in the browser. The signing secret belongs on your backend only.
- Using the event id of a quest-level event for a step. Each step event needs its own event_id and step_key.
- Signing a v2 string for a condition event. Condition steps need the v3 string, with empty fields kept as empty strings.
- Counting progress yourself. Read it from the execution.
- Treating complete as proof. It cannot verify a step.
- Forgetting the runtime header on raw HTTP. The quest then looks missing.
- Editing steps and expecting running participants to change. They keep their version; only new participants get the new steps.