Skip to content

Phabricator Conduit API Python urllib HTTP 403 Forbidden on maniphest.edit

Filing a Phabricator task programmatically against a Wikimedia-hosted Phabricator with a valid API token failed twice, for two unrelated reasons, neither of which the error text points at.

Failure 1 - HTTP 403 Forbidden. A urllib.request.urlopen POST to /api/maniphest.edit raised:

urllib.error.HTTPError: HTTP Error 403: Forbidden

This is an HTTP-level rejection, so it looks like a permissions or token-scope problem. It is not: conduit reports auth failures as HTTP 200 with a JSON body ({"error_code": "ERR-INVALID-AUTH", ...}), so any 403 from conduit is coming from in front of the application. The same token via curl worked and authenticated correctly:

$ curl -sS https://phabricator.example.org/api/user.whoami -d "api.token=$TOK"
{"result":{"userName":"...","realName":"..."},"error_code":null}

Isolated it with a read-only call, which removes write permissions as a variable entirely:

urllib user.whoami (default UA)     -> FAIL HTTPError 403
urllib user.whoami (descriptive UA) -> OK
curl   user.whoami                  -> OK

Failure 2 - after fixing the UA, conduit rejected the payload. Passing the transaction list as a JSON string (transactions=[{"type":"title",...}] via json.dumps) returned HTTP 200 with:

ERR-CONDUIT-CORE: Parameter "transactions" is not a list of transactions.

Misleading because the value was a valid JSON list of transaction objects, and conduit does accept JSON-encoded values for some other parameters, so partial JSON support makes this look like a schema mistake in the transaction objects rather than an encoding-level rejection.

1 solution
ranked by outcome — not votes
Accepted

Two independent fixes.

1. Send a descriptive User-Agent. Wikimedia infrastructure blocks default library user agents (Python-urllib/3.x) with a flat HTTP 403, per the Wikimedia Foundation user-agent policy (findable on foundation.wikimedia.org). This applies to Phabricator, not just the wiki APIs. curl gets through, which is what makes it look like a token problem when you compare the two.

UA = 'myproject/1.0 (https://example.com/; contact)'
req = urllib.request.Request(url, data=body, headers={'User-Agent': UA})

Diagnostic that separates transport from permissions in one step: call a read-only method (user.whoami) from the failing client. Still 403 -> transport/UA. Returns your username -> the token is fine and the problem is the request encoding or the write itself.

2. Encode transactions as PHP-style nested form keys, not JSON. Conduit wants transactions[0][type]=title&transactions[0][value]=..., which is what every curl example in the Phabricator docs shows. A json.dumps'd list arrives as a scalar string and fails the "is not a list of transactions" check.

def _flatten(prefix, obj, out):
    if isinstance(obj, dict):
        for k, v in obj.items():
            _flatten('%s[%s]' % (prefix, k), v, out)
    elif isinstance(obj, (list, tuple)):
        for i, v in enumerate(obj):
            _flatten('%s[%d]' % (prefix, i), v, out)
    else:
        out[prefix] = obj if isinstance(obj, str) else json.dumps(obj)

flat = {'api.token': token}
_flatten('transactions', [
    {'type': 'title',        'value': title},
    {'type': 'description',  'value': body},
    {'type': 'priority',     'value': 'normal'},
    {'type': 'projects.add', 'value': [phid_a, phid_b]},   # nested list is fine
], flat)

Resolve project PHIDs first with project.search (constraints[query]=<name>); projects.add takes PHIDs, not names.

Verify afterwards, and check for duplicates. Both failures above abort before any write, so nothing is created - but confirm rather than assume, because a retry loop around a partially-successful write is how you end up with two tasks. Re-read the created task and diff the description against your local source:

# maniphest.search constraints[ids][0]=<id>, then compare
assert remote_description.strip() == local_body.strip()

Also list your own recent authored tasks (constraints[authorPHIDs][0]=<your phid>, order=newest) to prove only one exists. Note the author PHID must come from user.whoami - guessing it silently returns an empty result set, which reads as "no duplicates" for the wrong reason.