Trying to update existing account give 400 Bad Request

Your question

Im trying to update quota and password of an existing account with the following request:

{
  "using" : [ "urn:ietf:params:jmap:core", "urn:stalwart:jmap" ],
  "methodCalls" : [ [ "x:Account/set", {
    "update" : {
      "b" : {
        "quotas" : {
          "maxDiskQuota" : 9437184,
          "maxCalendars" : 2
        },
        "credentials" : [ {
          "@Type" : "Password",
          "secret" : "{crypt}$2a$12$xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
        } ]
      }
    },
    "0" : "a"
  } ] ]
}

But I get a 400 Bad Request back:

{
  "type" : "urn:ietf:params:jmap:error:notRequest",
  "status" : 400,
  "detail" : "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"update\":{\"b\":{\"quotas\":{\"maxDiskQuota\":9437184,\"maxCalendars\":2},\"credentials\":[{\"@Type\":\"Password\",\"secret\":\"{crypt}$2a$12$xxxxxxxxxxxxxxxxxxxxxxx\"}]}},\"0\":\"a\"}]]}"
}

Any idea what (the hash is replaced with x for obvious reasons)?

Ralf

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

Let me look up the correct JMAP API format for account management in Stalwart.

There are two separate issues in the request that are causing the 400 error:

1. Wrong structure for quotas

The quotas field is typed as Map<StorageQuota, UnsignedInt> – it is a plain key/value map, not an object with named sub-fields. maxDiskQuota and maxCalendars are valid StorageQuota enum keys, but they must be passed as a flat map:

"quotas": {
  "maxDiskQuota": 9437184,
  "maxCalendars": 2
}

That part of your request actually looks correct already, so this may not be the culprit.

2. Wrong structure for credentials (most likely cause)

The credentials field is a list of Credential objects. Each item is a multi-variant type discriminated by "@type", not "@Type" (capital T). The correct discriminator key is @type (all lowercase). The supported Password variant looks like:

"credentials": [
  {
    "@type": "Password",
    "secret": "plaintextpassword"
  }
]

Two things to fix here:

  • Use "@type" (lowercase t), not "@Type".
  • The secret field must be a plain-text string – Stalwart handles hashing internally. Pre-hashed strings like {crypt}$2a$12$... are not accepted via this API path.

3. Wrong method call ID reference

Your methodCalls array has "0": "a" as a third element in the method call tuple. Per JMAP (RFC 8620), the third element should be an arbitrary call-reference string (like "c1"). A numeric key "0" is not a valid method call reference in this position and may itself cause the request to be rejected as malformed (notRequest).

The corrected call should look like:

{
  "using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
  "methodCalls": [
    [
      "x:Account/set",
      {
        "update": {
          "<account-id>": {
            "quotas": {
              "maxDiskQuota": 9437184,
              "maxCalendars": 2
            },
            "credentials": [
              {
                "@type": "Password",
                "secret": "plaintextpassword"
              }
            ]
          }
        }
      },
      "c1"
    ]
  ]
}

Docs: Account object reference and AccountPassword/set.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

So apart from the method reference in the wrong position, there’s no way but a SQL directory to set an already hashed password?

If that’s true, it means an external directory is always necessary for a migration, at least if you’re - as you should - not string plaintext passwords.

That does not really make sense. I would understand that you’re not supporting every possible hash, but no hashes at all?

{"methodResponses":[["x:Account/set",{"accountId":"b","notUpdated":{"b":{"type":"invalidPatch","description":"Invalid value for object property","properties":["credentials"]}}},"a"]],"sessionState":"da906780"}

Ralf

A couple of separate things are going on. First the request shape: in JMAP a method call is a three-element array, [methodName, arguments, callId], so the “a” id has to be the third element of that array, not a “0”: “a” entry inside the arguments object. That malformed form is what produced the notRequest on the first attempt.

Second, credentials isn’t a JSON array; it’s a map keyed by the credential id as a string, and the type discriminator is @type in lowercase, not @Type. That array is what trips the invalidPatch on credentials. And to settle the bigger concern: you can set an already-hashed password through this API. A secret that begins with $ (bcrypt $2a$…, argon2 PHC) or a {scheme} prefix like {crypt} is recognized and stored verbatim, not re-hashed; the only time a credential change is refused is when the account’s domain is backed by an external directory. No SQL directory needed.

So updating the password credential (id 0 here) looks like:

{
  "using": ["urn:ietf:params:jmap:core","urn:stalwart:jmap"],
  "methodCalls": [
    ["x:Account/set",
      { "update": {
          "b": {
            "quotas": { "maxDiskQuota": 9437184, "maxCalendars": 2 },
            "credentials": { "0": { "@type": "Password", "secret": "{crypt}$2a$12$...." } }
          }
      } },
      "a"
    ]
  ]
}