Build

KoboToolbox guide

REST Services post each submission to a small relay that rewards it.

How it fits together

KoboToolbox can post each submission to a URL, but it cannot sign a request the way Jusa's completions endpoint wants or hold a secret key safely. So a small relay of yours sits between them: KoboToolbox posts to it, it checks the post really came from your form, and it calls POST /rewards, keyed on the submission so each is paid once, forever.

  1. Make a program (dashboard or POST /programs) with the budget, the amount and per_recipient={"lifetime": 1} if each person is paid once.
  2. Make a restricted key with only rewards:write, and give it to the relay. If it ever leaks, it can reward from that program's budget and do nothing else.
  3. Add a question for the respondent's mobile number to the form (and their meter, if you pay ZESA).

In KoboToolbox

  1. Open the project, then Settings → REST Services, and register a service pointing at your relay, for example https://relay.example.org/kobo, sending JSON.
  2. Add a custom HTTP header with a long random value, for example X-Relay-Token: 3f9c…. Kobo cannot sign the body, so this shared value is how the relay knows the post is yours; keep it out of the form.
  3. Each submission arrives as JSON with your question names as keys and Kobo's own fields, among them _uuid, a unique id for the submission. That is the completion_id.

The relay

import hmac, os, jusa
from flask import Flask, request, abort

jusa.api_key = os.environ["JUSA_REWARDS_KEY"]          # jusa_rk_… with rewards:write only
app = Flask(__name__)

@app.post("/kobo")
def kobo():
    if not hmac.compare_digest(request.headers.get("X-Relay-Token", ""), os.environ["RELAY_TOKEN"]):
        abort(403)
    s = request.get_json()
    reward = jusa.Reward.create(
        program=os.environ["JUSA_PROGRAM"],
        recipient={"phone": s["respondent_phone"]},   # your question's name
        completion_id=s["_uuid"],
        metadata={"form": str(s.get("_xform_id_string", ""))},
        idempotency_key=f"kobo-{s['_uuid']}",
    )
    return {"reward": reward.id}, 200
  • Kobo retries a failed post; completion_id makes a repeat the same reward (200), never a second one.
  • Return 2xx even when Jusa refuses (a cap, a blocked number): the refusal is final for that submission, and GET /rewards?completion_id=… shows it. Return 5xx only when Jusa could not be reached, so Kobo tries again.
  • Test first with a test key and a test program: numbers ending 0002 fail, 0010 are held for review.

Mint reward links before fieldwork (POST /reward-links {"count": 500, "bind": "otp"}) and have enumerators hand out the QR codes; each respondent claims on their own phone. See the claim cards recipe.