Skip to content

210 × 297 mm

Quotes & estimates

Phased project estimate

An A4 estimate for agencies and studios that plan work in phases. Tasks sit inside each phase with days and day rates, optional extras are listed separately and left out of the total, and it adds a timeline, payment schedule, assumptions and a signature block.

A4 portraitLocale en-GBHTML + JinjaHeader & footer

Opens Dynamic Document API with this template preloaded in a free workspace.

Template language features

  • Loops
  • Conditionals
  • Macros
  • Filters
  • Currency formatting
  • Date formatting
  • Number formatting
  • Markdown
  • Repeating table headers
  • Header & footer
  • Page numbers

Filters such as format_currency and qrcode are described in the template language reference.

Variables

Derived from the sample data: 7 top-level fields, 42 paths in total. Send the same shape as data.

PathTypeExample
studioobject
studio.namestring"Copperbeck Studio Ltd"
studio.short_namestring"Copperbeck Studio"
studio.addressstring"Unit 5, Gasholder Yard ⏎ 41 Ropemaker Lane ⏎ L…"
studio.emailstring"hello@copperbeck.example.com"
studio.phonestring"+44 20 7946 0321"
studio.websitestring"copperbeck.example.com"
studio.company_numberstring"12345678"
studio.vat_numberstring"GB 123 4567 89"
clientobject
client.namestring"Hollin Vale Museum Trust"
client.contactstring"Dr Amara Lindqvist, Director of Audiences"
client.addressstring"The Old Mill, Mill Lane ⏎ Hollin Vale ⏎ Hebden…"
quoteobject
quote.numberstring"CBS-E-2026-041"
quote.titlestring"Collections website and online booking"
quote.summarystring"A new public website that brings the museum's …"
quote.issue_datedate"2026-09-16"
quote.valid_for_daysnumber30
quote.currencystring"GBP"
quote.localestring"en-GB"
quote.vat_ratenumber0.2
quote.prepared_bystring"Rhys Abernethy"
quote.prepared_by_rolestring"Client Partner"
phasesarray<object> · 4
phases[].namestring"Discovery"
phases[].summarystring"Understand visitors, collections data and the …"
phases[].start_weeknumber1
phases[].weeksnumber2
phases[].tasksarray<object> · 3
phases[].tasks[].namestring"Stakeholder workshops"
phases[].tasks[].detailsstring"Two half-day sessions with curators, learning …"
phases[].tasks[].daysnumber2
phases[].tasks[].day_ratenumber650
phases[].tasks[].amountnumber1300
payment_schedulearray<object> · 3
payment_schedule[].milestonestring"On acceptance"
payment_schedule[].sharenumber0.3
assumptionsstring"- The museum provides a single point of contac…"
acceptanceobject
acceptance.studio_signatorystring"Rhys Abernethy"
acceptance.studio_signatory_rolestring"Client Partner"

Sample data

Fictional records used for previews. Replace them with your own data when you call the API.

data.json
{
  "studio": {
    "name": "Copperbeck Studio Ltd",
    "short_name": "Copperbeck Studio",
    "address": "Unit 5, Gasholder Yard\n41 Ropemaker Lane\nLondon N1 7XY",
    "email": "hello@copperbeck.example.com",
    "phone": "+44 20 7946 0321",
    "website": "copperbeck.example.com",
    "company_number": "12345678",
    "vat_number": "GB 123 4567 89"
  },
  "client": {
    "name": "Hollin Vale Museum Trust",
    "contact": "Dr Amara Lindqvist, Director of Audiences",
    "address": "The Old Mill, Mill Lane\nHollin Vale\nHebden Bridge HX7 8XY"
  },
  "quote": {
    "number": "CBS-E-2026-041",
    "title": "Collections website and online booking",
    "summary": "A new public website that brings the museum's collection online, lets visitors book timed entry and events, and gives staff a content system they can run without developer help.",
    "issue_date": "2026-09-16",
    "valid_for_days": 30,
    "currency": "GBP",
    "locale": "en-GB",
    "vat_rate": 0.2,
    "prepared_by": "Rhys Abernethy",
    "prepared_by_role": "Client Partner"
  },
  "phases": [
    {
      "name": "Discovery",
      "summary": "Understand visitors, collections data and the current ticketing set-up.",
      "start_week": 1,
      "weeks": 2,
      "tasks": [
        {
          "name": "Stakeholder workshops",
          "details": "Two half-day sessions with curators, learning and visitor services teams",
          "days": 2,
          "day_rate": 650,
          "amount": 1300
        },
        {
          "name": "Visitor research",
          "details": "Eight remote interviews and an on-site survey over two weekends",
          "days": 3,
          "day_rate": 650,
          "amount": 1950
        },
        {
          "name": "Technical audit",
          "details": "Collections database export, ticketing system and analytics review",
          "days": 1.5,
          "day_rate": 650,
          "amount": 975
        }
      ]
    },
    {
      "name": "Design",
      "summary": "Shape the structure, look and feel, and test it with real visitors.",
      "start_week": 3,
      "weeks": 3,
      "tasks": [
        {
          "name": "Information architecture",
          "details": "Site map, navigation model and content types",
          "days": 2,
          "day_rate": 650,
          "amount": 1300
        },
        {
          "name": "Visual design system",
          "details": "Typography, colour, components and accessibility annotations",
          "days": 4,
          "day_rate": 650,
          "amount": 2600
        },
        {
          "name": "Clickable prototype and usability testing",
          "details": "Two rounds of testing with five participants each",
          "days": 3,
          "day_rate": 650,
          "amount": 1950
        },
        {
          "name": "Audio guide page designs",
          "details": "Mobile-first object pages with transcripts for the gallery audio tour",
          "days": 1.5,
          "day_rate": 650,
          "amount": 975,
          "optional": true
        }
      ]
    },
    {
      "name": "Build",
      "summary": "Develop, integrate and test the website on a staging environment.",
      "start_week": 5,
      "weeks": 6,
      "tasks": [
        {
          "name": "Front-end templates and component library",
          "details": "Responsive templates built from the approved design system",
          "days": 8,
          "day_rate": 600,
          "amount": 4800
        },
        {
          "name": "Collections search integration",
          "details": "Nightly import from the collections database with faceted search",
          "days": 6,
          "day_rate": 600,
          "amount": 3600
        },
        {
          "name": "Booking flow and payment provider integration",
          "details": "Timed entry, events and gift memberships",
          "days": 5,
          "day_rate": 600,
          "amount": 3000
        },
        {
          "name": "Accessibility testing and fixes",
          "details": "WCAG 2.2 AA audit, including screen reader and keyboard checks",
          "days": 3,
          "day_rate": 600,
          "amount": 1800
        },
        {
          "name": "Recurring donations page",
          "details": "Monthly giving form connected to the existing payment provider",
          "days": 2.5,
          "day_rate": 600,
          "amount": 1500,
          "optional": true
        }
      ]
    },
    {
      "name": "Launch and handover",
      "summary": "Move content across, train the team and support the go-live.",
      "start_week": 11,
      "weeks": 3,
      "tasks": [
        {
          "name": "Content migration support",
          "details": "Import scripts and editorial checks for around 240 pages",
          "days": 4,
          "day_rate": 450,
          "amount": 1800
        },
        {
          "name": "Staff CMS training",
          "details": "Two recorded sessions and a printed quick-start guide",
          "days": 1,
          "day_rate": 650,
          "amount": 650
        },
        {
          "name": "Launch and early-life support",
          "details": "Go-live support and two weeks of priority fixes",
          "days": 2,
          "day_rate": 600,
          "amount": 1200
        },
        {
          "name": "Twelve-month support retainer",
          "details": "Up to 1.5 days a month for updates, monitoring and small changes",
          "days": 18,
          "day_rate": 400,
          "amount": 7200,
          "optional": true
        }
      ]
    }
  ],
  "payment_schedule": [
    { "milestone": "On acceptance", "share": 0.3 },
    { "milestone": "Design sign-off", "share": 0.4 },
    { "milestone": "Launch", "share": 0.3 }
  ],
  "assumptions": "- The museum provides a single point of contact who can gather feedback and approve each phase within **five working days**.\n- Collections data can be exported nightly in CSV or JSON format; no changes to the collections database itself are included.\n- The booking flow uses the museum's existing payment provider account.\n- Content is supplied or rewritten by museum staff; we provide templates, guidance and migration scripts.\n- Hosting is quoted separately and is not part of this estimate.\n- Prices exclude VAT. Travel within Greater London is included; other travel is charged at cost with prior approval.",
  "acceptance": {
    "studio_signatory": "Rhys Abernethy",
    "studio_signatory_role": "Client Partner"
  }
}

Source

The body markup with Jinja, the stylesheet placed in <head> and the header and footer settings.

{#- Phased project estimate. Tasks are nested inside phases; tasks flagged "optional": true are
    listed separately and never counted in the phase subtotals or the total. Totals are derived by
    mapping filters over the phases, so there is nothing to keep in sync by hand. -#}
{%- set locale = quote.locale | default("en-GB") -%}
{%- set currency = quote.currency -%}
{%- set task_lists = phases | map(attribute="tasks") | list -%}
{%- set subtotal = task_lists | map("rejectattr", "optional") | map("sum_by", "amount") | sum -%}
{%- set total_days = task_lists | map("rejectattr", "optional") | map("sum_by", "days") | sum -%}
{%- set optional_count = task_lists | map("selectattr", "optional") | map("list") | map("length") | sum -%}
{%- set optional_total = task_lists | map("selectattr", "optional") | map("sum_by", "amount") | sum -%}
{%- set vat = (subtotal * quote.vat_rate) | round(2) -%}
{%- set total = subtotal + vat -%}
{%- set valid_until = quote.issue_date | date_add(days=quote.valid_for_days) -%}
{%- set last_phase = phases | last -%}
{%- set total_weeks = last_phase.start_week + last_phase.weeks - 1 -%}

{%- macro money(value) -%}{{ value | format_currency(currency, locale) }}{%- endmacro -%}
{%- macro days(value) -%}{{ value | format_number(locale, 0 if value == value | int else 1) }}{%- endmacro -%}

<header class="letterhead">
  <div class="brand">
    <svg class="leaf" viewBox="0 0 40 40" aria-hidden="true">
      <path d="M20 3C9 11 7 24 20 37 33 24 31 11 20 3z" fill="#b4532a"/>
      <path d="M20 9v27M20 17l-5-4M20 23l6-5M20 29l-6-4" stroke="#f7e3d6" stroke-width="1.6" stroke-linecap="round" fill="none"/>
    </svg>
    <span class="brand-name">{{ studio.short_name }}</span>
  </div>
  <div class="contact">
    {{ studio.website }} · {{ studio.email }} · {{ studio.phone }}
  </div>
</header>

<section class="cover">
  <p class="eyebrow">Project estimate · {{ quote.number }}</p>
  <h1>{{ quote.title }}</h1>
  <p class="lede">{{ quote.summary }}</p>

  <div class="cover-grid">
    <div>
      <p class="label">Prepared for</p>
      <p class="strong">{{ client.name }}</p>
      <p>{{ client.contact }}</p>
      <p class="muted">{{ client.address | nl2br }}</p>
    </div>
    <div>
      <p class="label">Prepared by</p>
      <p class="strong">{{ quote.prepared_by }}</p>
      <p>{{ quote.prepared_by_role }}, {{ studio.name }}</p>
      <p class="muted">{{ studio.address | nl2br }}</p>
    </div>
    <div>
      <p class="label">Dates</p>
      <p>Issued {{ quote.issue_date | format_date("long", locale) }}</p>
      <p><strong>Valid until {{ valid_until | format_date("long", locale) }}</strong></p>
    </div>
  </div>
</section>

<section class="glance">
  <div class="stat stat-main">
    <span class="stat-k">Estimate excl. VAT</span>
    <span class="stat-v">{{ money(subtotal) }}</span>
  </div>
  <div class="stat">
    <span class="stat-k">Effort</span>
    <span class="stat-v">{{ days(total_days) }} days</span>
  </div>
  <div class="stat">
    <span class="stat-k">Timeline</span>
    <span class="stat-v">{{ total_weeks }} weeks</span>
  </div>
  <div class="stat">
    <span class="stat-k">Phases</span>
    <span class="stat-v">{{ phases | length }}</span>
  </div>
</section>

<section class="timeline">
  <h2 class="section-title">Timeline</h2>
  <div class="tl-row tl-scale">
    <div class="tl-label">Week</div>
    <div class="tl-track">
      {% for week in range(1, total_weeks + 1) %}<span style="width: {{ (100 / total_weeks) | round(4) }}%">{{ week }}</span>{% endfor %}
    </div>
  </div>
  {% for phase in phases %}
  <div class="tl-row">
    <div class="tl-label"><span class="tl-no">{{ "%02d" | format(loop.index) }}</span> {{ phase.name }}</div>
    <div class="tl-track">
      <span class="tl-bar" style="margin-left: {{ ((phase.start_week - 1) / total_weeks * 100) | round(4) }}%; width: {{ (phase.weeks / total_weeks * 100) | round(4) }}%"></span>
    </div>
  </div>
  {% endfor %}
</section>

<h2 class="section-title">Scope and costs by phase</h2>
{% for phase in phases %}
{%- set phase_no = loop.index -%}
{%- set included = phase.tasks | rejectattr("optional") | list -%}
<article class="phase">
  <header class="phase-head">
    <span class="phase-no">{{ "%02d" | format(phase_no) }}</span>
    <div class="phase-title">
      <h3>{{ phase.name }}</h3>
      <p>{{ phase.summary }}</p>
    </div>
    <div class="phase-meta">
      Week{{ "s" if phase.weeks > 1 else "" }} {{ phase.start_week }}{% if phase.weeks > 1 %}–{{ phase.start_week + phase.weeks - 1 }}{% endif %}
      <strong>{{ money(included | sum_by("amount")) }}</strong>
    </div>
  </header>
  <table class="tasks">
    <thead>
      <tr>
        <th class="ref" scope="col">Ref</th>
        <th scope="col">Task</th>
        <th class="num" scope="col">Days</th>
        <th class="num" scope="col">Day rate</th>
        <th class="num" scope="col">Amount</th>
      </tr>
    </thead>
    <tbody>
      {% for task in included %}
      <tr>
        <td class="ref">{{ phase_no }}.{{ loop.index }}</td>
        <td>
          <span class="task-name">{{ task.name }}</span>
          {% if task.details %}<span class="task-details">{{ task.details }}</span>{% endif %}
        </td>
        <td class="num">{{ days(task.days) }}</td>
        <td class="num">{{ money(task.day_rate) }}</td>
        <td class="num">{{ money(task.amount) }}</td>
      </tr>
      {% endfor %}
      <tr class="phase-total">
        <td></td>
        <td>{{ phase.name }} subtotal</td>
        <td class="num">{{ days(included | sum_by("days")) }}</td>
        <td></td>
        <td class="num">{{ money(included | sum_by("amount")) }}</td>
      </tr>
    </tbody>
  </table>
</article>
{% endfor %}

<section class="totals">
  <table class="phase-summary">
    <thead>
      <tr><th scope="col">Phase</th><th class="num" scope="col">Days</th><th class="num" scope="col">Amount</th></tr>
    </thead>
    <tbody>
      {% for phase in phases %}
      {%- set included = phase.tasks | rejectattr("optional") | list -%}
      <tr>
        <td>{{ "%02d" | format(loop.index) }} · {{ phase.name }}</td>
        <td class="num">{{ days(included | sum_by("days")) }}</td>
        <td class="num">{{ money(included | sum_by("amount")) }}</td>
      </tr>
      {% endfor %}
    </tbody>
  </table>
  <table class="grand">
    <tr><th scope="row">Subtotal</th><td class="num">{{ money(subtotal) }}</td></tr>
    <tr><th scope="row">VAT {{ quote.vat_rate | format_percent(locale) }}</th><td class="num">{{ money(vat) }}</td></tr>
    <tr class="grand-total"><th scope="row">Total</th><td class="num">{{ money(total) }}</td></tr>
  </table>
</section>

{% if optional_count > 0 %}
<section class="optional">
  <h2 class="section-title">Optional extras</h2>
  <p class="optional-intro">Not included in the total above. Any of these can be added when you accept the estimate.</p>
  <table class="tasks">
    <thead>
      <tr>
        <th class="tick" scope="col">Add</th>
        <th scope="col">Extra</th>
        <th scope="col">Phase</th>
        <th class="num" scope="col">Days</th>
        <th class="num" scope="col">Amount</th>
      </tr>
    </thead>
    <tbody>
      {% for phase in phases %}
      {% for task in phase.tasks | selectattr("optional") %}
      <tr>
        <td class="tick"><span class="box" role="img" aria-label="Tick box"></span></td>
        <td>
          <span class="task-name">{{ task.name }}</span>
          {% if task.details %}<span class="task-details">{{ task.details }}</span>{% endif %}
        </td>
        <td class="nowrap">{{ phase.name }}</td>
        <td class="num">{{ days(task.days) }}</td>
        <td class="num">{{ money(task.amount) }}</td>
      </tr>
      {% endfor %}
      {% endfor %}
      <tr class="phase-total">
        <td></td>
        <td colspan="3">All {{ optional_count }} extras, excl. VAT</td>
        <td class="num">{{ money(optional_total) }}</td>
      </tr>
    </tbody>
  </table>
</section>
{% endif %}

<section class="schedule">
  <h2 class="section-title">Payment schedule</h2>
  <table class="schedule-table">
    <tbody>
      {% for step in payment_schedule %}
      <tr>
        <td class="step-no">{{ loop.index }}</td>
        <th scope="row">{{ step.milestone }}</th>
        <td class="num">{{ step.share | format_percent(locale) }}</td>
        <td class="num">{{ money((subtotal * step.share) | round(2)) }} <span class="muted">+ VAT</span></td>
      </tr>
      {% endfor %}
    </tbody>
  </table>
</section>

<section class="assumptions">
  <h2 class="section-title">Assumptions</h2>
  {{ assumptions | markdown }}
</section>

<section class="acceptance">
  <h2 class="section-title">Acceptance</h2>
  <p class="acceptance-intro">
    To go ahead, sign below and return this page before {{ valid_until | format_date("long", locale) }}.
    Signing accepts estimate {{ quote.number }} for {{ money(subtotal) }} excluding VAT, plus any optional extras you tick.
  </p>
  <div class="sign-grid">
    <div class="sign-box">
      <p class="label">For {{ studio.name }}</p>
      <div class="field"><span>Signature</span></div>
      <div class="field filled"><span>Name</span>{{ acceptance.studio_signatory }}</div>
      <div class="field filled"><span>Role</span>{{ acceptance.studio_signatory_role }}</div>
      <div class="field"><span>Date</span></div>
    </div>
    <div class="sign-box">
      <p class="label">Accepted for {{ client.name }}</p>
      <div class="field"><span>Signature</span></div>
      <div class="field"><span>Name</span></div>
      <div class="field"><span>Role</span></div>
      <div class="field"><span>Date</span></div>
      <div class="field"><span>PO number</span></div>
    </div>
  </div>
</section>

Render it with the API

After adding the template to your workspace, render it by ID and pass your data. Arrays are shortened here.

curl -X POST https://api-eu.dynamicdocumentapi.com/v1/renders \
  -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "input": {
    "type": "template",
    "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C"
  },
  "data": {
    "studio": {
      "name": "Copperbeck Studio Ltd",
      "short_name": "Copperbeck Studio",
      "address": "Unit 5, Gasholder Yard\n41 Ropemaker Lane\nLondon N1 7XY",
      "email": "hello@copperbeck.example.com",
      "phone": "+44 20 7946 0321",
      "website": "copperbeck.example.com",
      "company_number": "12345678",
      "vat_number": "GB 123 4567 89"
    },
    "client": {
      "name": "Hollin Vale Museum Trust",
      "contact": "Dr Amara Lindqvist, Director of Audiences",
      "address": "The Old Mill, Mill Lane\nHollin Vale\nHebden Bridge HX7 8XY"
    },
    "quote": {
      "number": "CBS-E-2026-041",
      "title": "Collections website and online booking",
      "summary": "A new public website that brings the museum'\''s collection online, lets visitors book timed entry and events, and gives staff a content system they can run without developer help.",
      "issue_date": "2026-09-16",
      "valid_for_days": 30,
      "currency": "GBP",
      "locale": "en-GB",
      "vat_rate": 0.2,
      "prepared_by": "Rhys Abernethy",
      "prepared_by_role": "Client Partner"
    },
    "phases": [
      {
        "name": "Discovery",
        "summary": "Understand visitors, collections data and the current ticketing set-up.",
        "start_week": 1,
        "weeks": 2,
        "tasks": [
          {
            "name": "Stakeholder workshops",
            "details": "Two half-day sessions with curators, learning and visitor services teams",
            "days": 2,
            "day_rate": 650,
            "amount": 1300
          },
          {
            "name": "Visitor research",
            "details": "Eight remote interviews and an on-site survey over two weekends",
            "days": 3,
            "day_rate": 650,
            "amount": 1950
          }
        ]
      },
      {
        "name": "Design",
        "summary": "Shape the structure, look and feel, and test it with real visitors.",
        "start_week": 3,
        "weeks": 3,
        "tasks": [
          {
            "name": "Information architecture",
            "details": "Site map, navigation model and content types",
            "days": 2,
            "day_rate": 650,
            "amount": 1300
          },
          {
            "name": "Visual design system",
            "details": "Typography, colour, components and accessibility annotations",
            "days": 4,
            "day_rate": 650,
            "amount": 2600
          }
        ]
      }
    ],
    "payment_schedule": [
      {
        "milestone": "On acceptance",
        "share": 0.3
      },
      {
        "milestone": "Design sign-off",
        "share": 0.4
      }
    ],
    "assumptions": "- The museum provides a single point of contact who can gather feedback and approve each phase within **five working days**.\n- Collections data can be exported nightly in CSV or JSON format; no changes to the collections database itself are included.\n- The booking flow uses the museum'\''s existing payment provider account.\n- Content is supplied or rewritten by museum staff; we provide templates, guidance and migration scripts.\n- Hosting is quoted separately and is not part of this estimate.\n- Prices exclude VAT. Travel within Greater London is included; other travel is charged at cost with prior approval.",
    "acceptance": {
      "studio_signatory": "Rhys Abernethy",
      "studio_signatory_role": "Client Partner"
    }
  },
  "output": {
    "format": "pdf",
    "filename": "quote-project-estimate.pdf"
  }
}'