DocsDocs
  • Reference
  • Live today

Invite request API

The same invite request as the request form, as two HTTPS calls, for software that asks for a Pigeon invite for a person. The invite code goes to the person's own email address, never back to the caller.

The person must have read and agreed to the Terms and the Privacy Policy, and want an invite sent to their email address. Sending accept_terms: true says they have.

The two calls

First get a proof-of-work challenge, then send the request with its solution.

GET https://intake.pigeonmessage.io/v1/challengeA proof-of-work challenge, valid for 5 minutes and for one request: challenge (send it back as it is), nonce, salt, cost, key_length, key_prefix, max_counter, expires_at
POST https://intake.pigeonmessage.io/v1/invite-requestJSON: email, accept_terms (must be true), challenge, solution, and optionally on_behalf_of, the calling software's name and version (up to 100 characters). Answers 200 {"status": "received", "request_id": "r-…"}

The solution is the counter n, from 0 to max_counter, for which PBKDF2-HMAC-SHA256 of the nonce followed by n as 4 big-endian bytes, with the salt, cost iterations and key_length bytes, starts with key_prefix in hex. It takes a few seconds.

The POST also carries the SHA-256 of its body, in hex, in an x-amz-content-sha256 header: the edge checks it. Hash the exact bytes you send. Without it, or if it doesn't match, the answer is a 403 from the edge rather than one of the errors below.

A worked example

A solver with Python's standard library, solve.py. It writes the body to body.json and prints that file's SHA-256:

solve.pypython
import hashlib, json, sys
c = json.load(sys.stdin)
nonce, salt = bytes.fromhex(c["nonce"]), bytes.fromhex(c["salt"])
n = next(n for n in range(c["max_counter"] + 1)
         if hashlib.pbkdf2_hmac("sha256", nonce + n.to_bytes(4, "big"), salt, c["cost"],
                                c["key_length"]).hex().startswith(c["key_prefix"]))
body = json.dumps({"email": sys.argv[1], "accept_terms": True, "challenge": c["challenge"],
                   "solution": n, "on_behalf_of": "ExampleApp/1.0"}).encode()
open("body.json", "wb").write(body)
print(hashlib.sha256(body).hexdigest())

Then the two calls:

Terminalshell
hash=$(curl -sf https://intake.pigeonmessage.io/v1/challenge | python3 solve.py person@example.org)
curl -s https://intake.pigeonmessage.io/v1/invite-request \
  -H 'content-type: application/json' \
  -H "x-amz-content-sha256: $hash" \
  --data-binary @body.json

Errors

Every error is JSON, {"error": "<code>", "reason": "<what to fix>"}. The codes are stable; the reasons may be reworded.

400bad_request, email_missing, email_invalid, agree_missing (accept_terms isn't true), on_behalf_of_invalid, pow_missing, pow_invalid, pow_expired
404, 405not_found, method
409pow_replayed: get a new challenge for each request
413, 415too_large (over 8192 bytes), bad_type (not application/json)
422email_disposable, email_no_mail (the domain can't receive mail)
429ip_limited, with a retry-after header in seconds
500, 503internal, unavailable, dns_unavailable: nothing was kept; retry later

Limits

  • 5 requests an hour from one network, counted together with the request form.
  • One challenge per request, spent once, valid for 5 minutes.
  • Asking again for the same address gets the same answer; we hear about it once a day at most.
  • No throwaway email services, and the address's domain must receive mail.

The address is kept for 15 days after the last request for it, then deleted automatically (usually within 2 days after that). An email alias works.

What we keep, and for how long, is in the privacy policy. Questions: support@pigeonmessage.io.