Turn existing forms into structured JSON in seconds
Import a PDF, DOCX, HTML, Markdown or text file, or a public URL. Whatever the source, Pufflet returns the same JSON shape, keeping the original wording, structure and logic, with review notes when anything needs attention.
On the left, page 2 of a blank PDF intake form: a required yes or no question about employer coverage, a policy number to fill in only if the answer is yes, and a box to attach a photo of an insurance card. On the right, the same page as a builder form: the yes or no question, the policy number with its rule kept, and the photo request as a line of text, with a review note saying the file upload became text because this builder has no upload field.
- ✓Read the document
- ✓Identified questions, rules and sections
- ✓Checked against the source
Imported and checked against the source. 2 questions, 1 rule, 1 note.
Two ways to use Pufflet
Use Pufflet as a customer-facing import feature, or behind the scenes to automate form conversion work.
Add form import to your product
Let users bring the forms they already use directly into your application instead of rebuilding them by hand.
See how it worksAutomate form conversion and migration
Use Pufflet behind the scenes for onboarding, migration, implementation, digitization and other form-conversion work.
Talk to us about conversion and migrationSee what Pufflet does with your form
Upload a form or paste a public URL to see the structured JSON result, its review notes and a builder preview. The live demo is by invitation: enter your code, or request a private demo.
Checking your demo access
From existing document to converted form in three steps.
Provide the existing form
Upload a file or paste a public URL, from inside your product or as part of your own conversion work. No retyping or reformatting necessary.
PDFDOCXHTMLMarkdownTextURLPufflet reads, converts, then verifies
Pufflet parses the form, turns it into structured JSON, then verifies the result against the source. Anything uncertain or changed is surfaced for review, not silently dropped.
The JSON response is ready for your builder or system
Open the form in a builder or pass the JSON on to your system. Anything requiring attention can be surfaced for review.
SectionsQuestions & choicesRequiredShow-when rulesReview notes
The small things are the whole job
A form is more than its questions. It's which ones are required, which only appear after a certain answer, what counts as “Other”, and where a section ends. Those details are easy to lose and slow to rebuild by hand. Pufflet returns them as structured data rather than flattened text, so the rules keep working after import.
Happier customers. Hours back for your team.
Up and running with the forms they already have
Customers start with familiar forms already in place, so onboarding feels easy.
- Get existing forms in place in minutes
- Less frustration rebuilding forms from scratch
- Fewer support tickets and complaints during onboarding
Less manual rebuilding, lower cost per project
Pufflet converts the form and flags anything that needs attention, so your team handles exceptions instead of rebuilding field by field.
- Cut hours of repetitive form-building work
- Lower the delivery cost of onboarding and migration projects
- Handle more projects without adding more manual effort
Your document is never stored.
Your file is never written to disk or to our database. When the request ends, both the document and its extracted text are gone.
- Uploaded straight to the APIOver HTTPS. The web page never touches the file.
- Read into memory onlyConverted to text. The filename is used only to determine the file type.
- Structured, not studiedGoogle (Gemini), OpenAI or Anthropic identifies each question, its type and its rules. Providers never train their models on the data we send.
- Returned once, then gonePast imports cannot be retrieved through the API.
What we keep
No document content. One record per import that includes:
- Who and what: account, API key reference, environment
- Size: bytes received, characters of extracted text, page count
- Result: success or failure code, question count, review note codes
- Processing: AI provider and model, tokens used, duration
What we never keep
The import record never includes:
- The document
- The extracted text
- The filename
- Any question wording or field values
Only import blank forms
Pufflet converts empty templates, not completed forms. It does not currently accept personal, patient or client information in either the demo or production service. If you need to convert forms in a regulated setting, contact us before sending documents.
Full security and data handling details
What happens to your document
Your file is read into memory and converted to text, then sent to an AI provider, which identifies the form's sections, questions and rules. The contents are never written to disk or to our database. When the request ends, both the document and its extracted text are gone.
We do not retain the filename. It is used only to determine the file type.
Past imports cannot be retrieved through the API. Each result is returned once in response to your request. This page keeps it only in the browser tab's memory.
What we keep
We keep one record per import. It contains no document content:
| Who and what | account, API key reference, environment, stored configuration reference |
|---|---|
| Size | bytes received, characters of extracted text, page count |
| Result | success or failure code, question count, verification status, review note codes and counts |
| Processing | AI provider and model, tokens used, estimated cost, duration |
| Versions | API, pipeline and format versions |
We keep this information to bill accurately, investigate reported failures and measure conversion quality over time. It contains no document text, filenames or field values.
Messages sent through this site. Messages submitted through this site's contact form are emailed to us. They are not written to our database, and the site does not retain a copy. Resend processes each message in transit on our behalf.
Importing from a URL
URL imports are limited to publicly routable HTTP and HTTPS addresses. Pufflet rejects loopback, private-range, link-local and cloud metadata addresses, as well as internal hostnames and URLs containing credentials. It follows no more than three redirects and validates every destination. Requests carry no credentials, time out after 20 seconds and stop at 20 MB.
Who else processes your document
A third-party AI provider identifies the sections, questions and rules in each document. Because we maintain multiple fallbacks for provider outages, your document may be handled by one of the following:
- Google (Gemini)
- OpenAI
- Anthropic
Every response identifies the provider and model that handled the import.
None of these providers trains its models on the data we send. Google does not use prompts or responses to improve its products on the paid Gemini tier we use. OpenAI does not train on API data unless an organisation opts in, and all data-sharing settings on our account are turned off. Anthropic does not train on commercial API traffic. Its only exception is feedback or bug reports, which we do not submit.
Processing locations. Our API runs in Virginia, USA, and our database uses the Firestore nam5 United States multi-region. The demo web page is served from Washington, DC, but documents never pass through it. Your browser uploads them directly to the API.
The AI providers are the exception. The interfaces we use do not restrict processing to a specific region, so this processing may occur anywhere those providers operate. If you require processing in a specific region, contact us to discuss your needs.
Who we are. Pufflet is a product of Guinan Technologies, based in Toronto, Canada.
Security controls
- All data is transmitted over HTTPS.
- Google Cloud Firestore encrypts our database at rest by default.
- Only our server can reach the database, using an administrative credential. Security rules deny all client access, so the database cannot be reached from a browser.
- API keys are stored only as SHA-256 hashes. Each key is shown once when created and cannot be recovered afterwards. Revocation takes effect on the next request.
- Each account can access only its own stored configuration. Requests containing another account's configuration reference are refused.
- Every key is subject to a request rate limit. Demo keys also have a fixed import allowance.
Personal and regulated data
Pufflet does not currently accept personal, patient or client information in either the demo or production service. We do not yet have the agreements or controls required to process that data.
If you need to convert forms in a regulated setting, contact us before sending documents so we can discuss your requirements.
What Pufflet gives you
Each import returns the form as structured data: its wording, its logic, its structure, and a note on anything worth checking.
Verbatim wording
Questions, labels and instructions are as written, never rewritten or summarized.
Form logic
Pufflet represents required settings, show-when conditions and branching structurally.
Document structure
Sections, lists, tables, helper text and links remain part of the resulting structure.
Choices and follow-ups
Choice options, “Other” fields and their follow-up questions stay connected.
Review notes
Uncertain, set-aside or unsupported content is surfaced instead of silently dropped.
One consistent schema
PDF, DOCX, HTML, Markdown, text and URL imports all return the same structured JSON format.
config
Tell Pufflet what the target builder or system supports. Elements outside those capabilities are downgraded to work within its constraints, and always generate a review note.
"supports": { "kinds": [ … no "file_upload" ], "branching": false, "formatting": { "markers": { "link": true } } }
mapping
Pufflet's builder-neutral elements map to the target system's own element types through a small set of mapping tables.
Different inputs. One consistent JSON structure.
A Pufflet integration has three parts: the configuration sent with each request, mappings from Pufflet JSON to your editor's element types, and review notes shown to your users. Field names and structure remain consistent across all source formats.
- ConfigurationDefines what your builder can render. Send it with each request, or we can store it so you only need to pass its ID.
- MappingA small set of tables translates Pufflet's JSON into your editor's element types and field names.
- Review notesEach note has a code, a severity and a plain-language message. We recommend showing all of them to your users.
{ "id": "q8",
"kind": "short_answer",
"label": "Policy number",
"required": null,
"show_when": { "question": "q7",
"operator": "equals",
"value": "Yes" } }
// a review note
{ "code": "kind_downgraded",
"severity": "info",
"message": "…" }Each import returns one JSON document containing the converted form, review notes (each marked as needing review or as information), items set aside from the form (such as page numbers, images, and buttons), a clean or needs-review verdict, and source details.
See full integration details
Configuration sent by this page
Every request includes a configuration that defines what your builder can render. This demo uses the API defaults with four exceptions: its builder has no file-upload control, treats links as text, does not support branching or formatting in helper text, so a skip instruction in the source (“If No, skip to Part 5”) comes back as a note. A builder that supports branching leaves that setting on and receives the skip in the JSON. You can send your configuration with each request, or we can store it so you only need to pass its ID.
{
"config": {
"supports": {
"kinds": [
"short_answer",
"long_answer",
"single_choice",
"multi_choice",
"date",
"time",
"number",
"rating",
"matrix",
"ranking",
"signature"
],
"conditional_visibility": true,
"branching": false,
"formatting": {
"headings": true,
"questions": true,
"help_text": false,
"text": true,
"list_items": true,
"markers": {
"bold": true,
"italic": true,
"underline": true,
"link": true
},
"lists": true,
"tables": false
}
},
"other_followups": "conditional",
"limits": {
"max_pages": 200,
"max_chars": 2000000
}
}
}How this builder maps the JSON
Like yours, our form builder has its own element types and field names. A small set of mappings translates Pufflet's JSON into that vocabulary. These are the mappings used by the demo builder. A row without a note is a direct match: every field carries across unchanged.
| In the JSON | In this builder | Notes |
|---|---|---|
form.title | Text · Title, first element of page 1 | Editable and removable like any element. |
form.description | Text · Body, after the title | |
sections | A page break before every section after the first | The preview shows Page x of y. |
section.heading / section.description | Text · Title / Body at the top of that page | |
heading | Text · Subtitle | |
explanation | Text · Body | |
explanation with list: "bullet" | Text · Bullet list | Consecutive bullet items form one list element, one line per item. A builder that ignores the field shows each as a paragraph; one that turns off supports.formatting.lists never receives it. |
short_answer | Question · Text | format hint (email / phone / web address) kept |
long_answer | Question · Textarea | |
single_choice | Question · Single select | A two-option question the import recognises as yes/no, in the form's own language, with no Other option, becomes Yes/No. |
multi_choice | Question · Multi select | |
date / time / number | Question · Date / Time / Number | |
rating | Question · Rating scale (from, to, end labels) | |
matrix | Question · Matrix / grid (rows, columns, one or many per row) | |
ranking | Question · Ranking | |
signature | Question · Signature | |
file_upload | Never received | Not in this builder's supports list, so the API returns the request as a line of text with a kind_downgraded note. |
text_grid | Never received | A fill-in table (one written answer per cell). Not in this builder's supports list, so the API sends one short answer per cell, labelled row — column, under the table's own question, with a kind_downgraded note. |
unknown_question | Question with no type chosen | The pipeline's note is shown; pick a type. |
description | Helper text | Shown only when the question has one. |
[label](address) in any text | A link in the text | This builder declares supports.formatting.markers.link. A builder that does not gets the label alone and a link_dropped note carrying the address. Choices never carry links. |
required (true / false / null) | Required checkbox | false and null both show unchecked; an untouched value writes back unchanged. |
other { label, prompt, input } | Include Other: the option's wording and its answer box | prompt (a caption for the box) is carried through; it is only set when a follow-up question was merged into the option, which this page's config does not ask for. |
show_when | Conditional rule: show when <question> is/includes <value> | The reserved value "other" means the Other option. |
branches | Not copied (no branching) | Removed by the API because this page's config says the builder does not support branching; a branch_dropped note says which answer would have skipped to which section. |
ids | Kept as element ids | Review notes point at elements by id. |
page | Not shown | The source page the item was read from, for a PDF (null for other formats): provenance for checking a note against the document, never layout. |
Mapping JSON to this builder
This demo uses three mapping tables for text levels, question types and field names, together with the code that applies them. Your integration uses the same approach with values from your own editor.
// Reading Pufflet's form JSON into this builder: three tables and the code that applies them.
// Your builder's version is this file with your own names in the tables.
import type { Form, Item, Question } from "@pufflet/contracts";
import type {
BuilderElement,
BuilderForm,
QuestionElement,
QuestionType,
TextElement,
TextLevel
} from "./types";
// 1. Text. Headings, explanations and the form's own title and intro all
// become Text elements; this is the level each one gets.
const TEXT: Record<
| "form_title"
| "form_description"
| "section_heading"
| "section_description"
| "heading"
| "explanation",
TextLevel
> = {
form_title: "title",
form_description: "body",
section_heading: "title",
section_description: "body",
heading: "subtitle",
explanation: "body"
};
// 2. Question kinds → this builder's input types.
const KINDS: Record<Question["kind"], QuestionType> = {
short_answer: "text",
long_answer: "textarea",
single_choice: "radio",
multi_choice: "checkbox",
date: "date",
time: "time",
number: "number",
rating: "rating",
matrix: "matrix",
ranking: "ranking",
signature: "signature",
file_upload: "", // refused: the builder asks for a type (and the API never sends one here)
text_grid: "", // refused the same way: no text grid here, so the API sends one short answer per cell
unknown_question: "" // no type chosen; the builder asks for one
};
// 3. Question fields → this builder's field names. Same value, new name.
// Absent or null in the JSON means absent here. Every JSON field is listed:
// null means "handled below" (shape changes) or "not copied", so a field
// added to the JSON later is a compile error here, not a silent drop.
type QuestionField = Question extends infer Q ? (Q extends unknown ? keyof Q : never) : never;
const FIELDS: Record<QuestionField, keyof QuestionElement | null> = {
id: null,
kind: null,
label: null,
description: "helperText",
required: "required",
format: "format",
choices: "options",
rows: "rows",
columns: "columns",
multi: "multi",
notes: "notes",
scale: null,
show_when: null,
// Not copied: this builder does not support branching (the demo's config
// says so, `supports.branching: false`), so the API removes each branch and
// a branch_dropped note says which answer would have skipped to which
// section; the form shows every section in order, as the printed form
// does. A builder that supports branching maps it to a jump between sections.
branches: null,
yes_no: null, // handled below
other: null,
// Not copied: the source page the item was read from (PDF only) is
// provenance for a reader checking a note against the document; this
// builder has nowhere to show it.
page: null
};
export function readItem(item: Item): BuilderElement {
if (item.kind === "heading" || item.kind === "explanation") {
return { id: item.id, type: "page_text", level: TEXT[item.kind], text: item.text };
}
const q: QuestionElement = {
id: item.id,
type: "question",
questionType: KINDS[item.kind],
label: item.label
};
const fields = item as Partial<Record<QuestionField, unknown>>;
for (const from of Object.keys(FIELDS) as QuestionField[]) {
const to = FIELDS[from];
const value = fields[from];
if (to && value !== undefined && value !== null)
Object.assign(q, { [to]: Array.isArray(value) ? [...value] : value });
}
// The few fields whose shape changes, not just their name.
if (item.kind === "rating") {
q.scale = {
min: item.scale.min,
max: item.scale.max,
minLabel: item.scale.min_label,
maxLabel: item.scale.max_label
};
}
if (item.show_when) {
q.rules = [
{
action: "show",
when: {
questionId: item.show_when.question,
operator: item.show_when.operator,
value: item.show_when.value
}
}
];
}
if ("other" in item && item.other) {
q.allowOther = true;
q.otherOptionLabel = item.other.label; // "Other (please specify)"
q.otherInput = item.other.input; // "none" | "short" | "long"
if (item.other.prompt !== null) q.otherLabel = item.other.prompt;
if (item.other.input === "long") q.otherMultiline = true;
}
// A yes/no question: the pair the import recognised in the form's own
// language (2026-09-30), or, for a form from before that field, the
// English words. The options keep the verbatim words either way, so a
// condition's value still matches what the respondent picks.
if (
item.kind === "single_choice" &&
!item.other &&
item.choices.length === 2 &&
(item.yes_no !== undefined || (item.choices[0] === "Yes" && item.choices[1] === "No"))
) {
q.questionType = "yes_no";
if (item.yes_no) q.options = [item.yes_no.yes, item.yes_no.no];
}
return q;
}
// 1b. Bullets. A run of explanation items marked list: "bullet" is one
// Text · Bullet list element, one line per item; the ids ride along so
// the list writes back as the same items.
function readList(items: Item[], start: number): { element: TextElement; end: number } {
const run: { id: string; text: string }[] = [];
let end = start;
for (; end < items.length; end++) {
const item = items[end];
if (item.kind !== "explanation" || item.list !== "bullet") break;
run.push(item);
}
return {
element: {
id: run[0].id,
type: "page_text",
level: "list",
text: run.map((i) => i.text).join("\n"),
ids: run.map((i) => i.id)
},
end
};
}
export function readForm(form: Form): BuilderForm {
const text = (id: string, slot: keyof typeof TEXT, value: string): TextElement => ({
id,
type: "page_text",
level: TEXT[slot],
text: value
});
const elements: BuilderElement[] = [];
if (form.title !== null) elements.push(text("form_title", "form_title", form.title));
if (form.description !== null)
elements.push(text("form_description", "form_description", form.description));
form.sections.forEach((section, i) => {
if (i > 0) elements.push({ id: section.id, type: "page_break" });
if (section.heading !== null)
elements.push(text(`${section.id}_heading`, "section_heading", section.heading));
if (section.description !== null)
elements.push(
text(`${section.id}_description`, "section_description", section.description)
);
for (let k = 0; k < section.items.length;) {
const item = section.items[k];
if (item.kind === "explanation" && item.list === "bullet") {
const { element, end } = readList(section.items, k);
elements.push(element);
k = end;
} else {
elements.push(readItem(item));
k++;
}
}
});
return { elements };
}Review note codes
Each review note includes a code, a severity and a plain-language message. The API returns all three; the short labels below are used in our demo builder. The severity is “review” when something needs attention, and “info” when the note is merely explanatory.
We recommend displaying all review notes to your users. They explain what changed, why it changed and what may need attention after import.
| Code | Severity | What it means |
|---|---|---|
ambiguous_kind | Needs review | Question type unclear |
conditional_unresolved | Needs review | Condition dropped: target not found |
conditional_both_branches | Needs review | Left unconditional by design |
other_followup_merged | Information | Follow-up merged into Other |
other_followup_linked | Information | Follow-up linked to Other |
other_choice_moved | Information | Write-in choice moved to Other |
question_missing | Needs review | Question could not be put back |
question_restored | Needs review | Question restored as text |
question_unverified | Needs review | Question needs review |
choices_mismatch | Needs review | Choices differ from source |
content_unsourced | Needs review | Text not found in source |
content_missing | Needs review | Text could not be put back |
content_restored | Needs review | Text put back as help text |
order_changed | Needs review | Order differs from source |
response_incomplete | Needs review | Result may be incomplete |
document_chunked | Information | Processed in parts |
kind_downgraded | Information | Converted to a supported type |
condition_dropped | Information | Condition dropped: not supported here |
label_supplied | Needs review | Label supplied |
questions_merged | Information | Questions combined into one |
multi_step_form | Needs review | Only the first step reached |
unreadable_text | Needs review | Some text could not be read |
no_questions_found | Needs review | No questions found |
duplicates_merged | Information | Repeated choices merged |
table_as_text | Information | Table returned as text |
cells_split | Information | Fill-in row split into fields |
link_dropped | Needs review | Link kept as text |
branch_unresolved | Needs review | Skip instruction not applied |
branch_dropped | Information | Skip not supported here |
Pricing that scales with your usage
Every plan includes the full API. Plans differ only in how many credits are included each month.
- StarterUS$49/month/moGet demo access100 creditsincluded each monthcredits / monthUS$0.60per additional creditper extra creditUS$0.49 / included creditUS$0.49per included credit
- GrowthUS$199/month/moGet demo access600 creditsincluded each monthcredits / monthUS$0.40per additional creditper extra creditUS$0.33 / included creditUS$0.33per included credit
- ScaleUS$499/month/moGet demo access2,000 creditsincluded each monthcredits / monthUS$0.30per additional creditper extra creditUS$0.25 / included creditUS$0.25per included credit
For migrations, bulk conversions and other high-volume workloads.
Project and volume pricing
How credits work
1 credit covers up to 10,000 extracted characters in one imported document. Longer documents use additional credits in 10,000-character increments.
Character count and credit usage are reported with every import.
| Characters | Credits used, as 10,000-character blocks | Credits |
|---|---|---|
| 7,500 | 1 credit | |
| 18,000 | 2 credits | |
| 34,000 | 4 credits |
Get demo access, or talk to us about volume
Try Pufflet with your own documents, or get in touch about a larger migration or integration.
A product of Guinan Technologies