Skip to main content
Version: 0.15

Issue a certificate

First-time issuance

Use an issue job when TokenTimer has no managed certificate for the identity yet and you want the result in inventory, not only on disk. TokenTimer creates the row at provisioning, links the job, and dispatches it to a capable agent.

Every other operation assumes the certificate already exists.

Request contract

  • No subjectType, subjectId, or payload.certificateId.
  • idempotencyKey is required (1–128 characters: letters, digits, ., _, :, -). The same body returns the original job. A different body with the same key is HTTP 409. Concurrent retries with the same key are serialized, so both clients receive the same job.
  • payload.certPath must be an absolute file path. Relative paths and trailing slashes are rejected up front so they cannot burn an ACME order. Omit this field for a windows-iis target.
  • No renewalProfile on the request. A profile is derived if issuance succeeds. See Renew certificates automatically.
  • Remaining fields match a renew payload: commandRef, caEndpoint, dnsZone, dnsProvider, target, sans, plus optional acmeKind, keyRotation, reloadService, verifyHost, verifyPort, and chainPath.

By default the agent writes the full chain (leaf plus intermediates) to certPath. Set chainPath to a second absolute path to split leaf and intermediates. chainPath is not validated up front.

curl -sS -b cookies.txt -X POST \
'https://your-tokentimer.example/api/v1/workspaces/WORKSPACE_ID/certops/jobs' \
-H 'Content-Type: application/json' \
-d '{
"operation": "issue",
"idempotencyKey": "issue-www-example-com-1",
"payload": {
"target": { "type": "domain", "reference": "www.example.com" },
"sans": ["www.example.com"],
"commandRef": "certbot-csr",
"caEndpoint": "https://acme-v02.api.letsencrypt.org/directory",
"dnsZone": "example.com",
"dnsProvider": "cloudflare",
"certPath": "/etc/ssl/tokentimer/www.example.com.pem"
}
}'

Issue jobs are offered only to agents that declare evidence-claim-binding-v1. If none is registered, the job stays pending. Retry a failed issuance with a renew job against the provisioning row; that retry uses the same claim rule.

On success the certificate becomes active, fingerprint and expiry are filled from verify evidence, and a renewal profile is derived. On failure it stays provisioning. Fix the cause and renew, or retire it. A failed issuance does not raise cert_renewal_failed; watch inventory for provisioning rows. See Certificate renewal failures.

Windows IIS target

target.type: "windows-iis" enrolls into a Windows machine store and an IIS site binding. The private key stays inside Windows CNG; it is never written as a file or .pfx. Use this with a Windows agent that declares windows-cert-store-v1 and iis-binding-v1. See Windows/IIS worked example.

  • target.store: almost always "My" (LocalMachine\My). A different store (for example WebHosting) enrolls into My first (certreq can only target that store), then mirrors into the requested store and removes the My copy. Prefer "My" unless IIS requires another store.
  • target.binding: { "site", "port", "sniHost?" }. Replaces certPath. Do not send certPath, chainPath, or keyPath.
  • TokenTimer sets keyMode: "os-store-managed" from the target type.
  • Optional sniHost binds hostnameport=. A non-SNI ipport= binding on the same port still wins for clients hitting that address (http.sys rule). The agent reports a non-fatal precedenceWarning when it sees a conflict.
  • No iisreset. Only the configured binding changes. The superseded certificate is kept for a grace window so rollback has a target.
{
"operation": "issue",
"idempotencyKey": "issue-iis-example-1",
"payload": {
"target": {
"type": "windows-iis",
"reference": "www.example.com",
"store": "My",
"binding": { "site": "Default Web Site", "port": 443, "sniHost": "www.example.com" }
},
"sans": ["www.example.com"],
"commandRef": "acmesh-signcsr",
"caEndpoint": "https://acme-v02.api.letsencrypt.org/directory",
"dnsZone": "example.com",
"dnsProvider": "cloudflare"
}
}

When a certificate stays at provisioning

Promotion to active needs verify-step evidence bound to the current job claim, with both a fingerprint and an expiry.

ReasonWhat happenedWhat to do
no_claim_bound_verify_evidenceSuccess reported without claim-bound verify evidenceUse an agent that declares evidence-claim-binding-v1, then renew. Check verifyHost
verify_evidence_missing_fingerprintVerify evidence had no SHA-256 fingerprintrenew again; inspect timeline evidence
verify_evidence_missing_expiryVerify evidence had no parseable end daterenew again; confirm the deployed file is a certificate

A provisioning row is not proof that nothing landed on the host. It means TokenTimer does not yet have proof of what is there. renew against that row is the normal fix.

A certificate can also reach active and still not auto-renew, when promotion succeeds but renewal-profile derivation declines. That case sets reconciliationReason to renewal_profile_<reason> (for example renewal_profile_derivation_failed for an incomplete issue payload). CERTOPS_RENEWAL_PROFILE_DERIVATION_DECLINED records the same decline.

The same three guards apply on later renewals of an active certificate. A guard failure sets reconciliationReason and writes CERTOPS_CERTIFICATE_RENEWAL_UNRECONCILED. The renewal scheduler's idempotency key is (certificateId, notAfter), so if notAfter never advances, the next pass can replay the same "successful" renewal against the CA. The dashboard does not surface reconciliationReason as a badge. Read reconciliationReason on GET .../certops/certificates or via that audit event.