# Task: Firmar una receta médica

I want my application to do what Lacuna's "Firmar una receta médica" demo does: Firme una receta de medicamentos con un A3 ICP-Brasil dentro de su propio sistema, con el CRM que verifica el validador del ITI, como lo haría una farmacia.

- Docs: https://docs.lacunasoftware.com/en-us/articles/signer/integration/document-fields
- Live demo: https://try.lacunasoftware.com/es/signer/sign-a-prescription
- This demo for AI agents: https://try.lacunasoftware.com/es/signer/sign-a-prescription.md

## Samples

- .NET (aspnet-mvc): [Sign a medical prescription (Receita Simples) using PKI SDK](https://github.com/LacunaSoftware/PkiSuiteSamples/blob/master/dotnet/mvc/PkiSuiteAspNetMvcSample/Controllers/ReceitaSimplesSdkController.cs)
- Java (spring-boot): [Sign a medical prescription (Receita Simples) using PKI Express](https://github.com/LacunaSoftware/PkiSuiteSamples/blob/master/java/springmvc/src/main/java/com/lacunasoftware/pkisuite/controller/ReceitaSimplesExpressController.java)
- Node.js (express): [Sign a medical prescription (Receita Simples) using PKI Express](https://github.com/LacunaSoftware/PkiSuiteSamples/blob/master/nodejs/expressmvc/routes/receita-simples-express.js)
- PHP (php): [Sign a medical prescription (Receita Simples) using PKI Express](https://github.com/LacunaSoftware/PkiSuiteSamples/blob/master/php/plain/public/receita-simples-express/index.php)
- Ruby (rails): [Sign a medical prescription (Receita Simples) using PKI Express](https://github.com/LacunaSoftware/PkiSuiteSamples/blob/master/ruby/rails/app/controllers/receita_simples_express_controller.rb)
- Lacuna's samples for this product: https://github.com/LacunaSoftware/SignerSamples

## Call sequence

Recorded on 2026-11-07 against the real products with Lacuna's public test certificates and data. Keys, tokens, sessions, ids, file contents and personal data are masked.

### 1. Dropsigner takes the PDF and creates a Prescription with the prescriber's CRM and UF, for one signer who may not sign electronically, notifications off; then it is read back and its signer checked.

On the server: `SignerClient.UploadFileAsync, then SignerClient.CreateDocumentAsync, then SignerClient.GetDocumentDetailsAsync`

`POST https://www.dropsigner.com/api/uploads` → 200

Request:

```json
"<multipart/form-data, 3724 bytes>"
```

Response:

```json
{
  "location": "/api/uploads/00000000-0000-0000-0000-000000000000?access_ticket={value}&culture=en&contentType={value}",
  "id": "00000000-0000-0000-0000-000000000000",
  "name": "Amostra-Receita-Simples.pdf",
  "contentType": "application/pdf"
}
```

`POST https://www.dropsigner.com/api/documents` → 200

Request:

```json
{
  "AreFilesScanned": false,
  "Files": [
    {
      "DisplayName": "[Lacuna Demo Site · binds no one] Amostra-Receita-Simples",
      "Id": "00000000-0000-0000-0000-000000000000",
      "Name": "Amostra-Receita-Simples.pdf",
      "ContentType": "application/pdf"
    }
  ],
  "IsEnvelope": false,
  "Type": 3,
  "FlowActions": [
    {
      "User": {
        "Name": "Alan Mathison Turing",
        "Identifier": "***.***.***-**",
        "Email": "someone@example.com"
      },
      "Type": "Signer",
      "Step": 1,
      "AllowElectronicSignature": false
    }
  ],
  "DisablePendingActionNotifications": false,
  "DisableNotifications": true,
  "ForceCadesSignature": false,
  "AdditionalInfo": {
    "HealthData": {
      "Professional": {
        "Name": "Alan Mathison Turing",
        "Id": "123456",
        "Region": "SP"
      }
    }
  },
  "SignatureType": 0,
  "ExpirationDate": "2026-11-08T00:00:00"
}
```

Response:

```json
[
  {
    "uploadId": "00000000-0000-0000-0000-000000000000",
    "documentId": "00000000-0000-0000-0000-000000000000"
  }
]
```

`GET https://www.dropsigner.com/api/documents/00000000-0000-0000-0000-000000000000` → 200

Response:

```json
{
  "checksumMd5": "<hex, 16 bytes>",
  "isDeleted": false,
  "flowActions": [
    {
      "id": "00000000-0000-0000-0000-000000000000",
      "shouldNotarize": false,
      "creationDate": "2026-11-07T22:35:42.4325889+00:00",
      "pendingDate": "2026-11-07T22:35:42.4327908+00:00",
      "updateDate": "2026-11-07T22:35:42.4327905+00:00",
      "user": {
        "id": "00000000-0000-0000-0000-000000000000",
        "name": "Alan Mathison Turing",
        "identifierType": "Cpf",
        "identifier": "***.***.***-**",
        "identifiers": [
          {
            "id": "00000000-0000-0000-0000-000000000000",
            "isGrant": true,
            "type": "Cpf",
            "value": "***.***.***-**"
          }
        ],
        "email": "someone@example.com",
        "createdBySubscriptionId": "00000000-0000-0000-0000-000000000000"
      },
      "numberRequiredSignatures": 0,
      "numberRequiredActions": 0,
      "allowElectronicSignature": false,
      "requireEmailAuthenticationToSignElectronically": false,
      "requireSmsAuthenticationToSignElectronically": false,
      "requireWhatsappAuthenticationToSignElectronically": false,
      "requireAuthenticatorAppToSignElectronically": false,
      "requireSelfieAuthenticationToSignElectronically": false,
      "requireDatavalidAuthenticationToSignElectronically": false,
      "requirePixAuthenticationToSignElectronically": false,
      "requireLivenessAuthenticationToSignElectronically": false,
      "requireIdScanAuthenticationToSignElectronically": false,
      "requireCompanyCertificate": false,
      "signatureInitialsMode": "None",
      "isElectronic": false,
      "allowRuleFlowToContinueIfRefused": false,
      "disableRefusal": false,
      "allowIdrcSignature": false,
      "signedViaIdrc": false,
      "notificationLanguage": "Pt",
      "visualRepresentationOnFootnote": false,
      "type": "Signer",
      "status": "Pending",
      "step": 1
    }
  ],
  "observers": [],
  "permissions": {
    "move": true,
    "editFlow": true,
    "viewSignedDocumentWithExternalFlowBeforeCompleted": true,
    "submitExternalFlowFiles": true,
    "updateDocumentVersion": true,
    "createAttachments": true,
    "deleteAttachments": true,
    "viewPrivateAttachments": true,
    "editAttachments": true,
    "createMeeting": true,
    "deleteMeeting": true,
    "refuse": true,
    "editInformation": true,
    "cancel": true,
    "editParticipantInformation": false,
    "pause": true
  },
  "pending": {},
  "hideDownloadOptionForPendingDocuments": false,
  "id": "00000000-0000-0000-0000-000000000000",
  "qrCode": "***",
  "name": "[Lacuna Demo Site · binds no one] Amostra-Receita-Simples",
  "key": "***",
  "filename": "Amostra-Receita-Simples.pdf",
  "fileSize": 4017,
  "mimeType": "application/pdf",
  "hasSignature": false,
  "status": "Pending",
  "isConcluded": false,
  "organization": {
    "id": "00000000-0000-0000-0000-000000000000",
    "name": "Lacuna Demo Site"
  },
  "isOwner": false,
  "creationDate": "2026-11-07T22:35:42.4325749+00:00",
  "updateDate": "2026-11-07T22:35:42.432576+00:00",
  "expirationDate": "2026-11-09T02:59:59.9999999+00:00",
  "expirationDateWithoutTime": "2026-11-08",
  "createdBy": {
    "id": "00000000-0000-0000-0000-000000000000",
    "name": "Lacuna Demo Site"
  },
  "type": "Prescription",
  "hasExternalFlow": false,
  "forceCadesSignature": false,
  "isScanned": false,
  "isEnvelope": false,
  "signatureType": "None",
  "signerPositioningEnabled": true,
  "automaticSignaturePositioning": "Footnote"
}
```

### 2. Dropsigner gives an action URL for the prescriber, asked for with the email read back alone, whose embed URL signs as them and is never logged or shown.

On the server: `SignerClient.GetActionUrlAsync`

`POST https://www.dropsigner.com/api/documents/00000000-0000-0000-0000-000000000000/action-url` → 200

Request:

```json
{
  "EmailAddress": "someone@example.com",
  "RequireEmailAuthentication": false
}
```

Response:

```json
{
  "url": "***",
  "embedUrl": "***"
}
```

### 3. The Signer widget renders the embed URL in a frame, inside a closed shadow root on the Demo's page, in the page's language.

In the browser: `widget.renderElement(embedUrl, root)`

### 4. The visitor signs in the frame with their own ICP-Brasil A3 and the widget posts documentSigned, which the Demo confirms by reading back; no recording can sign with an A3, so it is not performed.

In the browser: `documentSigned, the frame's message`

### 5. Start over cancels the document with its reason in the page's language; notifications are off, so nobody is emailed.

On the server: `SignerClient.CancelDocumentAsync`

`POST https://www.dropsigner.com/api/documents/00000000-0000-0000-0000-000000000000/cancellation` → 200

Request:

```json
{
  "Reason": "The visitor started over in the Lacuna Demo Site."
}
```

## Patterns to copy

- Create a Prescription with the ordinary create request: set `Type = DocumentTypes.Prescription` and `AdditionalInfo.HealthData.Professional = { Name, Id = CRM, Region = UF }`, and leave `Items` out. The Signer client has no health-document method, and none is needed. Signer then writes ITI's metadata and the CRM and UF into the PDF, and adds a footer with a QR code to its prescription page.
- Give a Prescription exactly one flow action, a `Signer` with `AllowElectronicSignature = false`, and nothing else that offers a way to sign without a certificate. Signer allows a health document only one signature and enforces the ICP-Brasil A3 itself, so `RequiredCertificateTypeToSign` isn't set. Its prescription errors, `HealthDocumentCanHaveOnlyOneSignature` and `InvalidCertificateForPrescription`, end as any send Dropsigner didn't take, with their code.
- To fill a prescriber from their own certificate in the browser, list their certificates through Web PKI once it reports ready, with no prompt, and offer only ICP-Brasil e-CPFs: a CPF, no CNPJ, within their validity dates, and not from Lacuna's test CA, which Signer refuses for a Prescription. Let the visitor pick; never fill the form without their click, even with one certificate. Read the picked certificate's encoding with `readCertificate` (no PIN), fill the name and CPF from it, locked, and send the encoding with the form. On the server, read the name and CPF from that encoding again, refuse a certificate that isn't an eligible e-CPF or fields that don't match it, and send Dropsigner the certificate's values, never the browser's. Apply the same CPF rule (check digits included) to Web PKI's listing as to the encoding, or the picker offers certificates the send refuses.
- The encoding comes from the browser, so parsing it proves nothing: anyone can make a certificate with a stranger's CPF. Before trusting it for anything beyond filling the form, such as letting a stored account's other name pass, validate its chain on the server against ICP-Brasil's roots alone, with PKI SDK's `TrustArbitrators.PkiBrazil`, and fail the send, never pass it, when the check can't run. Only a signature over a server nonce would also prove the visitor holds the key.
- To fill the CRM and UF from a physician's certificate too, read the Conselho Federal de Medicina's `otherName` entries from the encoding's Subject Alternative Name yourself, since neither Web PKI's certificate model nor PKI SDK maps them: `2.16.76.1.4.2.2.1` is the CRM and `2.16.76.1.4.2.2.2` its UF (ITI's ADE-ICP-04.01, section 21); ignore the specialty, `2.16.76.1.4.2.2.3`. Use them, locked, only when the CRM is 1 to 8 digits once punctuation is stripped and the UF is one of the 27; otherwise let the physician type them. On the server, read them from the encoding again, refuse fields that don't match them, and send them as the Prescription's health data.
- When Web PKI is missing or outdated, offer it behind a link the visitor can ignore, never as a prompt in the way of the typed form, and hide the link on phones. Open the install page in a new tab rather than redirecting with `redirectToInstallPage()`, which would lose the uploaded document and the typed form, and check for Web PKI again when the visitor comes back. Make Install a plain link with `target="_blank"`, not `window.open` from Blazor Server: that runs a round trip after the click, and browsers block it (Safari at once). In Safari with only Web PKI's extension, `init()` reports ready but listing fails with `native_not_installed`: treat that as Web PKI missing, and keep the install check mounted while it lists, so it catches it.
- Validate the CRM (1 to 8 digits, no punctuation) and the UF (one of the 27) in the form and again on the server, before any Dropsigner call.
- Refuse a PDF that already carries a signature before sending it: Signer allows a Prescription only one signature, so a PDF signed already can't become one. Look for a signature field with a value and for a `/ByteRange` that fits the file, since either alone misses some signed PDFs.
- For a Prescription signed in your own page, create it with `DisableNotifications = true`, so nobody is emailed, and a short expiry, because an abandoned document can't be reached again. Then embed it like any other document: ask for an action URL with the signer's email as read back, and render its `EmbedUrl` with the Signer widget.
- Treat the action URL's `EmbedUrl` as a credential: whoever opens it signs as the prescriber. Use it only if it is absolute HTTPS, never log it or put it in markup, and render the frame into a closed shadow root.
- Before asking for the action URL, compare the read-back signer with what you sent: Dropsigner keeps a known email's stored name and CPF, and its signing page would show them. One difference may pass, for a prescriber filled from their own certificate: an account holding that certificate's CPF, with its name written otherwise. Dropsigner's frame offers only certificates for that CPF, so the physician signs as their own account. Any other difference cancels, and so does any difference for a prescriber who typed their data.
- The Signer widget's events are only hints: confirm by reading the document back, and show the result only once Dropsigner reports the document Concluded and the flow action Completed.
- To let a returning physician skip typing, remember the prescriber in the browser only when they ask, and only once that read-back confirms the signature: data Dropsigner refused, or the stored-account check canceled, then never fills the form. Keep the prescriber as sent (name, CPF, email, CRM, UF) with its date, never the consent or the document, in `localStorage` rather than a cookie, and drop it unread once it is 90 days old. Fill the form from it only while the visitor hasn't typed, and offer to forget it only on a form it filled: forgetting erases it at once and clears only what it filled, never what the visitor typed over it or a certificate they picked.
- For a prescriber filled from a certificate, remember only the email and the certificate's thumbprint, never its fields. On return, pre-select that certificate if Web PKI still lists it and let the visitor's click fill the rest from it again; if it was renewed or removed, bring back only the email, so stale certificate data never comes back as if it were the certificate's.
- Once Dropsigner reports the document Concluded, offer the signed PDF (the `Signatures` ticket), point to its prescription page, `{Signer endpoint}HD/{key}`, and give the first 4 characters of the document's key: that is the code ITI's validator asks for when it reads the QR code. That QR code and code are how a pharmacy checks the prescription at validar.iti.gov.br: ITI follows the QR code to Dropsigner and downloads the signed PDF from there. Uploading the signed PDF to the validator also works, but ITI's simple report then shows only the signature.

## What to do

Implement this in my project with its existing stack, language, framework and conventions, following the call sequence and the patterns above, and adapting the samples rather than copying them whole. Ask me before adding any dependency or package. Keep API keys and private keys out of the browser and out of the repository.
