Build a Job Verification Ledger

Build a tamper-proof blockchain in Python to record and verify completed work.

Introduction

30 Second Summary

How do you prove that a job was actually completed? Records get lost, edited, or fabricated all the time.

In this project, you will build a blockchain from scratch in Python that records completed work as tamper-proof entries. You'll then build a web platform where anyone can upload documents and verify their authenticity against the chain.

What You'll Build

A tamper-proof job verification ledger powered by a Python blockchain, a Flask REST API, and a browser-based verification platform.

By the end of this project, you'll have:

  • A blockchain built from scratch with SHA-256 hashing, genesis block creation, proof-of-work mining, and chain validation.
  • A Flask REST API with endpoints to submit job records, mine blocks, view the chain, and verify integrity.
  • A web-based verification platform where users upload documents through a browser and get blockchain-backed proof of authenticity.
  • Secret Mission: Add digital signatures using HMAC so the ledger proves not just what was recorded, but who recorded it.

Are there any prerequisites?

You should be comfortable with Python basics like classes, dictionaries, and functions. You also need a code editor and Python 3 installed on your machine.

Before We Start

Before We Start...

Set Up Your Project

Before building your blockchain, you need a clean workspace with the right tools. Python handles the core blockchain logic using only built-in libraries.

In this step, you'll create a dedicated project folder, set up an isolated Python environment, and install Flask. Flask is the web framework that will power your verification platform's API and browser interface.

In this step, get ready to:
  • Create a project folder and Python virtual environment.
  • Install Flask.
  • Create the starter files and folders for the project.
Create the project folder and virtual environment

A Python virtual environment keeps your project's packages isolated from the rest of your system. This prevents version conflicts between different projects.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir blockchain-ledger
cd blockchain-ledger
  • Create a Python virtual environment by running:
python3 -m venv venv

What does this command do?

python3 -m venv runs the built-in Python module for creating virtual environments. The second venv is the name of the folder where the environment files are stored.

  • Confirm the virtual environment was created by running:
ls

You should see a venv folder in the output.

Don't see the venv folder?

If you see python3: command not found, try using python instead of python3. On Windows, the Python installer often adds python to your PATH instead of python3.

Help me set up a Python virtual environment

  • Activate the virtual environment by running the right command for your operating system:

macOS

source venv/bin/activate

Linux users can run this same command.

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

On Windows, PowerShell may block script execution by default. Run this command first, then try activating again:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

On macOS or Linux, make sure you typed source before venv/bin/activate. Without source, the activation script runs in a subprocess and your current terminal session stays unchanged.

Help me activate my virtual environment

Install Flask

Flask is a lightweight Python web framework. You'll use it later to build the API and browser-based interface for your blockchain ledger.

  • Install Flask by running:
pip install Flask==3.1.3

You should see output ending with Successfully installed followed by Flask and its dependencies.

Seeing an error during installation?

If you see pip: command not found, make sure your virtual environment is active. You should see (venv) in your terminal prompt.

If you see a permissions error, double check that you activated the virtual environment before running pip install. Installing inside the virtual environment does not require admin permissions.

Help me install Flask in my virtual environment

Create the project files and folders

Your project needs three things: blockchain.py for the blockchain logic, app.py for the Flask server, and a templates/ folder for HTML pages.

  • Create the starter files and the templates folder by running these commands:

macOS

touch blockchain.py app.py
mkdir templates

Linux users can run these same commands.

Windows

New-Item blockchain.py
New-Item app.py
mkdir templates
  • Confirm everything was created by running:
ls

You should see app.py, blockchain.py, templates, and venv in the output.

Don't see all four items?

Make sure you are inside the blockchain-ledger folder. Run pwd to check your current directory.

If a file is missing, re-run the creation command for just the missing item.

Help me set up my project files

Your project workspace is ready. Next up, you'll build the Block and Blockchain classes that form the core of your ledger.

Build the Block and Blockchain Classes

Your project folder is set up and ready to go. Now it's time to write the core logic that makes a blockchain work.

Every blockchain is built from two fundamental parts: blocks that store data, and a chain that links them together using cryptographic hashes. In this step, you'll build both from scratch in Python.

In this step, get ready to:
  • Create a Block class that computes its own SHA-256 hash from its contents.
  • Create a Blockchain class that initializes a chain with a genesis block and tracks pending job records.
  • Add proof-of-work mining and block hashing methods to the Blockchain class.
Create the Block class

A block is a single record in the chain. It holds data (like job records), a timestamp, and a unique fingerprint called a hash.

The hash is computed from all of the block's contents using SHA-256. If even one character of data changes, the hash changes completely. This is what makes tampering detectable.

  • Open blockchain.py in your code editor and add the following code:
import hashlib
import json
from datetime import datetime


class Block:
    def __init__(self, index, timestamp, transactions, proof, previous_hash):
        # Store the block's core data
        self.index = index
        self.timestamp = timestamp
        self.transactions = transactions
        self.proof = proof
        self.previous_hash = previous_hash
        # Compute this block's unique fingerprint from its contents
        self.hash = self.calculate_hash()

    def calculate_hash(self):
        # Serialize all fields to a consistent JSON string
        block_string = json.dumps({
            "index": self.index,
            "timestamp": self.timestamp,
            "transactions": self.transactions,
            "proof": self.proof,
            "previous_hash": self.previous_hash
        }, sort_keys=True)
        # Return the SHA-256 hex digest of the serialized block
        return hashlib.sha256(block_string.encode()).hexdigest()

    def to_dict(self):
        # Convert block to a plain dictionary for JSON responses
        return {
            "index": self.index,
            "timestamp": self.timestamp,
            "transactions": self.transactions,
            "proof": self.proof,
            "previous_hash": self.previous_hash,
            "hash": self.hash
        }

What does this code do?

The Block class stores five pieces of data: an index (its position in the chain), a timestamp, a list of transactions (job records), a proof number, and the hash of the previous block.

The calculate_hash() method turns all of these fields into a single JSON string using json.dumps() with sort_keys=True so the order is always consistent. Then it feeds that string into SHA-256 to produce a unique 64-character hex fingerprint.

The to_dict() method converts the block into a plain dictionary. You will use this later to return blocks as JSON from your API.

  • Save blockchain.py.
  • Test the Block class by running this command in your terminal:
python3 -c "from blockchain import Block; b = Block(0, '2026-01-01', [], 1, '0'); print(b.hash)"

You should see a 64-character hex string printed in your terminal. This is the SHA-256 hash of your first block.

Seeing a ModuleNotFoundError or SyntaxError?

  • Make sure you saved blockchain.py before running the command.
  • Check that your terminal is in the blockchain-ledger folder where blockchain.py lives.
  • Confirm your virtual environment is active. You should see (venv) at the start of your terminal prompt.
  • If you see an indentation error, make sure all the methods inside the Block class are indented by exactly 4 spaces.

Still stuck? Ask for help.

Create the Blockchain class

A single block on its own is just data. The blockchain is the structure that links blocks together and manages the chain as a whole.

Every blockchain starts with a special first block called the genesis block. It has no predecessor, so its previous_hash is set to "0". From that point on, every new block references the hash of the block before it.

  • In blockchain.py, below the Block class, add the Blockchain class by copying the following code:
class Blockchain:
    def __init__(self):
        # The chain holds all mined blocks
        self.chain = []
        # Pending transactions wait here until a block is mined
        self.pending_transactions = []
        # Every blockchain starts with a genesis block
        self.create_genesis_block()

    def create_genesis_block(self):
        # The first block has no predecessor, so previous_hash is "0"
        genesis_block = Block(0, str(datetime.now()), [], 1, "0")
        self.chain.append(genesis_block)

    def get_latest_block(self):
        # Return the most recently added block
        return self.chain[-1]

    def add_transaction(self, contractor, description, date):
        # Queue a job record to be included in the next mined block
        self.pending_transactions.append({
            "contractor": contractor,
            "description": description,
            "date": date
        })

What does this code do?

  • The __init__ method creates two empty lists (self.chain for mined blocks and self.pending_transactions for queued job records), then immediately creates the genesis block.
  • The create_genesis_block() method builds a Block with index 0, no transactions, and a previous hash of "0" since there is no block before it.
  • The add_transaction() method stores a job record (contractor name, description, and date) in the pending list. These records stay pending until a block is mined.
  • Save blockchain.py.
  • Test the Blockchain class by running this command:
python3 -c "from blockchain import Blockchain; bc = Blockchain(); print('Chain length:', len(bc.chain))"

You should see Chain length: 1 printed in your terminal. That single block is the genesis block your class just created.

Seeing Chain length: 0 or an error?

  • Make sure self.create_genesis_block() is called inside __init__. Without it, the chain starts empty.
  • Check that the Blockchain class is placed below the Block class. Python reads files top to bottom, so Blockchain needs Block to already be defined.

Still stuck? Ask for help.

What is a genesis block?

The genesis block is the very first block in any blockchain. Since there is no block before it, its previous_hash is set to a placeholder value ("0").

Every block that comes after it will store the hash of the block before it. This creates a chain where altering any block would break the link to every block that follows.

Add proof-of-work and block hashing

Right now, anyone could create a block instantly. Proof-of-work solves this by requiring the computer to solve a puzzle before a block is accepted. This makes forging blocks computationally expensive.

The puzzle is: find a number (called the "proof") that produces a SHA-256 hash starting with four zeros. The computer has to try thousands of numbers before it finds one that works.

  • In blockchain.py, inside the Blockchain class, add the proof_of_work method below the add_transaction method:
    def proof_of_work(self, previous_proof):
        # Start guessing from 1
        new_proof = 1
        while True:
            # Combine the new and previous proofs, then hash the result
            hash_operation = hashlib.sha256(
                str(new_proof ** 2 - previous_proof ** 2).encode()
            ).hexdigest()
            # Check if the hash starts with four zeros
            if hash_operation[:4] == "0000":
                return new_proof
            new_proof += 1

What does this code do?

The method takes the previous block's proof and tries numbers starting from 1. For each guess, it computes new_proof² - previous_proof², hashes the result with SHA-256, and checks if the hash starts with "0000".

Finding a valid proof requires trial and error. This is intentional. The difficulty (four leading zeros) makes it expensive to create blocks, which is what prevents someone from rewriting the chain.

  • Save blockchain.py.
  • Test proof-of-work by running this command:
python3 -c "from blockchain import Blockchain; bc = Blockchain(); print('Proof:', bc.proof_of_work(1))"

You should see a proof number printed (it will be an integer). This is the number your computer found that satisfies the four-leading-zeros condition.

Command hanging or taking too long?

  • Proof-of-work is supposed to take a moment. With four leading zeros, it typically finishes in under a second.
  • If it hangs for more than 10 seconds, check that the condition reads hash_operation[:4] == "0000" (four zeros, not five or more).

Still stuck? Ask for help.

The blockchain also needs a way to compute the hash of any existing block. This is used later to link blocks together and to verify the chain's integrity.

  • Below the proof_of_work method, still inside the Blockchain class, add the hash method:
    def hash(self, block):
        # Serialize the block's fields to a consistent JSON string
        block_string = json.dumps({
            "index": block.index,
            "timestamp": block.timestamp,
            "transactions": block.transactions,
            "proof": block.proof,
            "previous_hash": block.previous_hash
        }, sort_keys=True)
        # Return the SHA-256 hex digest
        return hashlib.sha256(block_string.encode()).hexdigest()

What does this code do?

This method takes a block object, serializes its fields into a JSON string with sort_keys=True for consistent ordering, and returns the SHA-256 hash. It is a class-level utility so other methods (like mining and validation) can compute a block's hash on demand.

The sort_keys=True part is critical. Without it, Python might serialize dictionary keys in a different order each time, producing a different hash for the same data.

  • Save blockchain.py.
  • Verify the hash method works by running this command:
python3 -c "from blockchain import Blockchain; bc = Blockchain(); print('Genesis hash:', bc.hash(bc.chain[0])[:16], '...')"

You should see a 16-character hex string followed by ... printed in your terminal. This is the first 16 characters of the genesis block's hash.

Seeing an AttributeError?

  • Make sure the hash method is indented inside the Blockchain class, not outside it.
  • Check that the method signature reads def hash(self, block): with both self and block as parameters.

Still stuck? Ask for help.

✔️ Awesome, I've got everything!

Great. Double check you've saved blockchain.py before moving on.

ⓧ I'd like to double check the full code

Here is the full blockchain.py file so far. Make sure your code matches this exactly.

import hashlib
import json
from datetime import datetime


class Block:
    def __init__(self, index, timestamp, transactions, proof, previous_hash):
        self.index = index
        self.timestamp = timestamp
        self.transactions = transactions
        self.proof = proof
        self.previous_hash = previous_hash
        self.hash = self.calculate_hash()

    def calculate_hash(self):
        block_string = json.dumps({
            "index": self.index,
            "timestamp": self.timestamp,
            "transactions": self.transactions,
            "proof": self.proof,
            "previous_hash": self.previous_hash
        }, sort_keys=True)
        return hashlib.sha256(block_string.encode()).hexdigest()

    def to_dict(self):
        return {
            "index": self.index,
            "timestamp": self.timestamp,
            "transactions": self.transactions,
            "proof": self.proof,
            "previous_hash": self.previous_hash,
            "hash": self.hash
        }


class Blockchain:
    def __init__(self):
        self.chain = []
        self.pending_transactions = []
        self.create_genesis_block()

    def create_genesis_block(self):
        genesis_block = Block(0, str(datetime.now()), [], 1, "0")
        self.chain.append(genesis_block)

    def get_latest_block(self):
        return self.chain[-1]

    def add_transaction(self, contractor, description, date):
        self.pending_transactions.append({
            "contractor": contractor,
            "description": description,
            "date": date
        })

    def proof_of_work(self, previous_proof):
        new_proof = 1
        while True:
            hash_operation = hashlib.sha256(
                str(new_proof ** 2 - previous_proof ** 2).encode()
            ).hexdigest()
            if hash_operation[:4] == "0000":
                return new_proof
            new_proof += 1

    def hash(self, block):
        block_string = json.dumps({
            "index": block.index,
            "timestamp": block.timestamp,
            "transactions": block.transactions,
            "proof": block.proof,
            "previous_hash": block.previous_hash
        }, sort_keys=True)
        return hashlib.sha256(block_string.encode()).hexdigest()

Your Block and Blockchain classes are ready. Next up, you'll add mining and chain validation so you can seal job records into blocks and verify that no one has tampered with them.

Add Mining and Chain Validation

In the last step, you built the Block and Blockchain classes. Your blockchain can create a genesis block, queue up job records, and solve proof-of-work puzzles.

But those pending job records are just sitting in a list with nothing to seal them into an actual block. In this step, you will add the ability to mine new blocks and then validate the entire chain's integrity.

In this step, get ready to:
  • Add a mine_block method that packages pending job records into a new block.
  • Add an is_chain_valid method that checks every block's hash and linkage.
  • Test the blockchain in your terminal to confirm everything works.
Add the mine_block method

Mining is the process that takes all pending job records, solves a proof-of-work puzzle, and seals everything into a new block on the chain. Once mined, those records are permanent.

  • In blockchain.py, find the hash method at the bottom of your Blockchain class.
  • Add the following mine_block method below it:
    def mine_block(self):
        # Get the last block's proof to feed into proof-of-work
        previous_block = self.get_latest_block()
        previous_proof = previous_block.proof
        proof = self.proof_of_work(previous_proof)
        # Compute the previous block's hash for chain linking
        previous_hash = self.hash(previous_block)

        # Create a new block with all pending job records
        block = Block(
            index=len(self.chain),
            timestamp=str(datetime.now()),
            transactions=self.pending_transactions,
            proof=proof,
            previous_hash=previous_hash
        )

        # Clear the pending list and add the block to the chain
        self.pending_transactions = []
        self.chain.append(block)
        return block

What does this code do?

  • self.get_latest_block() grabs the most recent block so we can read its proof and compute its hash.
  • self.proof_of_work(previous_proof) solves the computational puzzle. This is what makes mining take real work.
  • A new Block is created with all pending job records packaged inside it. The previous_hash links it cryptographically to the block before it.
  • After the block is appended to the chain, self.pending_transactions is cleared so the next batch of records starts fresh.
  • Save blockchain.py.

You will not see a change yet. The next method we add will let us verify the chain, and then we will test both together.

Add the is_chain_valid method

Mining creates blocks, but how do you know nobody has tampered with them after the fact? Chain validation walks through every block and checks that the hashes still match.

  • Still in blockchain.py, add the following method below mine_block:
    def is_chain_valid(self):
        # Walk every block starting after the genesis block
        for i in range(1, len(self.chain)):
            current_block = self.chain[i]
            previous_block = self.chain[i - 1]

            # Check that the stored hash still matches a fresh calculation
            if current_block.hash != current_block.calculate_hash():
                return False

            # Check that this block's previous_hash matches the actual previous block
            if current_block.previous_hash != self.hash(previous_block):
                return False

            # Verify the proof-of-work solution is still valid
            hash_operation = hashlib.sha256(
                str(current_block.proof ** 2 - previous_block.proof ** 2).encode()
            ).hexdigest()
            if hash_operation[:4] != "0000":
                return False

        return True

What does this code do?

This method performs three checks on every block in the chain (skipping the genesis block):

  • Hash integrity: Does the stored hash still match a freshly calculated one? If someone changed the block's data, the recalculated hash would be different.
  • Chain linkage: Does previous_hash actually match the hash of the block before it? This is what makes the chain a chain.
  • Proof-of-work validity: Does the proof still produce a hash starting with "0000"? This confirms the mining work was actually done.

If any check fails on any block, the method returns False immediately. The chain is only valid if every single block passes all three checks.

  • Save blockchain.py.

Seeing a syntax error?

  • Make sure both mine_block and is_chain_valid are indented one level inside the Blockchain class. They should line up with hash and proof_of_work.
  • Check that you have no extra blank lines or mixed tabs and spaces. Python is sensitive to indentation.

Why is Python throwing a syntax error in my blockchain?

✔️ Awesome, I've got everything!

Double check you have saved blockchain.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete blockchain.py file as it should look right now.

import hashlib
import json
from datetime import datetime


class Block:
    def __init__(self, index, timestamp, transactions, proof, previous_hash):
        self.index = index
        self.timestamp = timestamp
        self.transactions = transactions
        self.proof = proof
        self.previous_hash = previous_hash
        self.hash = self.calculate_hash()

    def calculate_hash(self):
        block_string = json.dumps({
            "index": self.index,
            "timestamp": self.timestamp,
            "transactions": self.transactions,
            "proof": self.proof,
            "previous_hash": self.previous_hash
        }, sort_keys=True)
        return hashlib.sha256(block_string.encode()).hexdigest()

    def to_dict(self):
        return {
            "index": self.index,
            "timestamp": self.timestamp,
            "transactions": self.transactions,
            "proof": self.proof,
            "previous_hash": self.previous_hash,
            "hash": self.hash
        }


class Blockchain:
    def __init__(self):
        self.chain = []
        self.pending_transactions = []
        self.create_genesis_block()

    def create_genesis_block(self):
        genesis_block = Block(0, str(datetime.now()), [], 1, "0")
        self.chain.append(genesis_block)

    def get_latest_block(self):
        return self.chain[-1]

    def add_transaction(self, contractor, description, date):
        self.pending_transactions.append({
            "contractor": contractor,
            "description": description,
            "date": date
        })

    def proof_of_work(self, previous_proof):
        new_proof = 1
        while True:
            hash_operation = hashlib.sha256(
                str(new_proof ** 2 - previous_proof ** 2).encode()
            ).hexdigest()
            if hash_operation[:4] == "0000":
                return new_proof
            new_proof += 1

    def hash(self, block):
        block_string = json.dumps({
            "index": block.index,
            "timestamp": block.timestamp,
            "transactions": block.transactions,
            "proof": block.proof,
            "previous_hash": block.previous_hash
        }, sort_keys=True)
        return hashlib.sha256(block_string.encode()).hexdigest()

    def mine_block(self):
        previous_block = self.get_latest_block()
        previous_proof = previous_block.proof
        proof = self.proof_of_work(previous_proof)
        previous_hash = self.hash(previous_block)

        block = Block(
            index=len(self.chain),
            timestamp=str(datetime.now()),
            transactions=self.pending_transactions,
            proof=proof,
            previous_hash=previous_hash
        )

        self.pending_transactions = []
        self.chain.append(block)
        return block

    def is_chain_valid(self):
        for i in range(1, len(self.chain)):
            current_block = self.chain[i]
            previous_block = self.chain[i - 1]

            if current_block.hash != current_block.calculate_hash():
                return False

            if current_block.previous_hash != self.hash(previous_block):
                return False

            hash_operation = hashlib.sha256(
                str(current_block.proof ** 2 - previous_block.proof ** 2).encode()
            ).hexdigest()
            if hash_operation[:4] != "0000":
                return False

        return True
Test the blockchain in your terminal

Before wiring up an API, it is worth confirming that the blockchain actually works. You can run a quick test directly in your terminal using a python3 -c command.

  • Make sure your terminal is inside the blockchain-ledger folder with your virtual environment activated.
  • Add two job records, mine a block, and validate the chain by running this command:
python3 -c "
from blockchain import Blockchain
import json

bc = Blockchain()
bc.add_transaction('Alice', 'Built login page', '2026-05-01')
bc.add_transaction('Bob', 'Designed database schema', '2026-05-01')
block = bc.mine_block()
print('Mined block:', json.dumps(block.to_dict(), indent=2))
print('Chain length:', len(bc.chain))
print('Chain valid:', bc.is_chain_valid())
"

You should see a JSON block with both job records inside the transactions array, a chain length of 2 (the genesis block plus your new block), and Chain valid: True at the bottom.

What should I see?

The mined block's JSON shows an index of 1 (since the genesis block is index 0), a proof number that the proof-of-work algorithm found, and a previous_hash that links back to the genesis block.

The hash field is a 64-character SHA-256 hex string. Your exact hash values will be different from anyone else's because the timestamp is unique to your machine.

Seeing a ModuleNotFoundError?

  • Make sure you are running the command from inside the blockchain-ledger folder where blockchain.py lives. Python looks for modules in the current directory.
  • Check that your virtual environment is activated. You should see (venv) at the start of your terminal prompt.

Why am I getting ModuleNotFoundError when importing my blockchain?

Your blockchain is complete and tested. Next up, you will wrap it in a Flask API so you can interact with it through HTTP requests from a browser or the command line.

Build the Flask API

In the last step, you tested your blockchain directly in the terminal by adding job records, mining blocks, and validating the chain.

Terminal commands work, but real blockchain nodes expose their functionality through a web API so that other programs (or people) can interact with them over the network. In this step, you will build a Flask REST API that wraps your blockchain so anyone can submit job records, mine blocks, view the chain, and verify its integrity using simple web requests.

In this step, get ready to:
  • Create a Flask app that imports and wraps your Blockchain class.
  • Build API endpoints for submitting jobs, mining blocks, viewing the chain, and validating integrity.
  • Start the Flask dev server and confirm it is running.
Set up Flask and create the job submission endpoint

Every Flask app starts with an app instance and the objects it needs to work with. Here, the app needs a Blockchain instance to store job records and manage the chain.

  • Open app.py in your code editor and add the following imports, app setup, and first endpoint:
import hashlib
from flask import Flask, jsonify, request, render_template, redirect, url_for

from blockchain import Blockchain

# Create the Flask app and a Blockchain instance
app = Flask(__name__)
blockchain = Blockchain()


# Accept a new job record and add it to pending transactions
@app.route("/jobs/new", methods=["POST"])
def new_job():
    data = request.get_json()

    # Validate that all required fields are present
    required_fields = ["contractor", "description", "date"]
    if not all(field in data for field in required_fields):
        return jsonify({"error": "Missing fields. Required: contractor, description, date"}), 400

    blockchain.add_transaction(
        contractor=data["contractor"],
        description=data["description"],
        date=data["date"]
    )

    return jsonify({"message": "Job record added to pending transactions"}), 201

What does this code do?

The imports pull in everything Flask needs for routing, JSON responses, file handling, and HTML rendering. You will use some of these imports in later steps.

The /jobs/new endpoint reads JSON from the request body with request.get_json(), checks that the required fields are present, and calls add_transaction() to queue the record for the next mined block. It returns a 201 status code on success and a 400 if any field is missing.

  • Save app.py.

You will not see a change yet. The next chunk adds the mining and chain viewing endpoints.

Create the mining and chain viewing endpoints

Job records sit in the pending list until someone mines a block. The /mine endpoint triggers that process, and the /chain endpoint lets anyone view the entire blockchain as JSON.

  • In app.py, add the following two endpoints below the new_job function:
# Mine a new block from pending job records
@app.route("/mine", methods=["GET"])
def mine():
    # Reject if there's nothing to mine
    if not blockchain.pending_transactions:
        return jsonify({"message": "No pending job records to mine"}), 400

    # Run proof-of-work and package pending records into a block
    block = blockchain.mine_block()

    return jsonify({
        "message": "New block mined successfully",
        "block": block.to_dict()
    }), 200


# Return the full blockchain as JSON
@app.route("/chain", methods=["GET"])
def get_chain():
    chain_data = [block.to_dict() for block in blockchain.chain]
    return jsonify({
        "chain": chain_data,
        "length": len(chain_data)
    }), 200

What does this code do?

The /mine endpoint first checks that there are pending job records. If there are, it calls mine_block() which runs proof-of-work, creates a new block containing all pending records, and clears the pending list.

The /chain endpoint converts every block in the chain to a dictionary using to_dict() and returns the full list along with the chain length. This is how anyone can inspect the entire ledger.

  • Save app.py.

You will not see a change yet. The next chunk adds chain validation and starts the server so you can test everything.

Why a REST API for a blockchain?

Real blockchain nodes expose their functionality through web APIs so that wallets, explorers, and other nodes can communicate with them over the network. Your Flask API follows the same pattern.

Each endpoint maps to a single blockchain operation: submit a record, mine, view, or validate. This makes the system easy to test and extend.

Add chain validation and start the server

The last endpoint lets anyone ask a critical question: has anyone tampered with the chain? This endpoint calls the is_chain_valid() method you built in the previous step and returns a clear yes-or-no answer.

  • In app.py, add the following code below the get_chain function:
# Check whether the blockchain has been tampered with
@app.route("/validate", methods=["GET"])
def validate():
    is_valid = blockchain.is_chain_valid()

    if is_valid:
        return jsonify({"message": "The blockchain is valid. All job records are authentic."}), 200
    else:
        return jsonify({"message": "INVALID! The blockchain has been tampered with."}), 200


# Start the Flask dev server when running this file directly
if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5001, debug=True)

What does this code do?

The /validate endpoint calls is_chain_valid() and returns a human-readable message saying whether the chain is intact or compromised.

The if __name__ == "__main__" block runs the Flask dev server on port 5001 when you execute the file directly. Port 5001 is used instead of the default 5000 because macOS Monterey and later use port 5000 for AirPlay Receiver.

  • Save app.py.

Now it is time to start the server and confirm everything is connected.

  • Make sure your virtual environment is still activated (you should see (venv) in your terminal prompt).
  • Start the Flask dev server by running this command:
python3 app.py
  • You should see output similar to:
 * Serving Flask app 'app'
 * Debug mode: on
 * Running on http://127.0.0.1:5001
  • Open your browser and go to http://127.0.0.1:5001/chain.

You should see a JSON response containing the genesis block and a chain length of 1. This confirms your Flask API is connected to the blockchain and serving data.

Server not starting?

  • If you see ModuleNotFoundError: No module named 'flask', your virtual environment is not activated. Run source venv/bin/activate and try again.
  • If you see Address already in use, another process is using port 5001. Close that process or change the port number in app.py.
  • If you see a SyntaxError, double check your app.py against the full code tab below. Missing colons, mismatched parentheses, or incorrect indentation are common causes.

Help me debug my Flask server startup error

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py and that your server is still running.

ⓧ I'd like to double check the full code

Here is the complete app.py file at this point in the project. Compare your file against this and fix any differences.

import hashlib
from flask import Flask, jsonify, request, render_template, redirect, url_for

from blockchain import Blockchain

app = Flask(__name__)
blockchain = Blockchain()


@app.route("/jobs/new", methods=["POST"])
def new_job():
    data = request.get_json()

    required_fields = ["contractor", "description", "date"]
    if not all(field in data for field in required_fields):
        return jsonify({"error": "Missing fields. Required: contractor, description, date"}), 400

    blockchain.add_transaction(
        contractor=data["contractor"],
        description=data["description"],
        date=data["date"]
    )

    return jsonify({"message": "Job record added to pending transactions"}), 201


@app.route("/mine", methods=["GET"])
def mine():
    if not blockchain.pending_transactions:
        return jsonify({"message": "No pending job records to mine"}), 400

    block = blockchain.mine_block()

    return jsonify({
        "message": "New block mined successfully",
        "block": block.to_dict()
    }), 200


@app.route("/chain", methods=["GET"])
def get_chain():
    chain_data = [block.to_dict() for block in blockchain.chain]
    return jsonify({
        "chain": chain_data,
        "length": len(chain_data)
    }), 200


@app.route("/validate", methods=["GET"])
def validate():
    is_valid = blockchain.is_chain_valid()

    if is_valid:
        return jsonify({"message": "The blockchain is valid. All job records are authentic."}), 200
    else:
        return jsonify({"message": "INVALID! The blockchain has been tampered with."}), 200


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5001, debug=True)

Your blockchain now has a proper API layer. Next up, you will put it to the ultimate test by submitting real job records, mining them onto the chain, and then deliberately tampering with the data to see exactly how the blockchain catches forgery.

Test the Ledger and Break It

Your Flask API is live and serving blockchain endpoints. Now it is time to put it through a real test.

You will submit job records, mine them into a block, and verify the chain is intact. Then you will deliberately tamper with a block to prove that blockchain makes falsification detectable.

In this step, get ready to:
  • Submit job records and mine them into a block.
  • Verify the chain's integrity.
  • Tamper with a block and prove the chain catches it.
Submit job records and mine a block

Your Flask server is running in your current terminal. You need a second terminal to send requests to it.

  • Open a new terminal window or tab.
  • Activate your virtual environment by running:
cd ~/Desktop/blockchain-ledger
source venv/bin/activate
  • Submit your first job record by running this command:
curl -X POST http://127.0.0.1:5001/jobs/new \
  -H "Content-Type: application/json" \
  -d '{"contractor": "Alice", "description": "Built authentication module", "date": "2026-04-15"}'

You should see a response that says Job record added to pending transactions.

What does this command do?

This curl command sends a POST request to your Flask API with a JSON body containing the contractor name, description of work, and date. The API adds this record to the blockchain's pending transactions list, waiting to be mined into a block.

On Windows?

Windows curl requires double quotes around the JSON body with escaped inner quotes. Replace the -d flag value with: -d "{\"contractor\": \"Alice\", ...}".

  • Submit a second job record by running:
curl -X POST http://127.0.0.1:5001/jobs/new \
  -H "Content-Type: application/json" \
  -d '{"contractor": "Bob", "description": "Deployed staging server", "date": "2026-04-20"}'

You should see the same success message. Both records are now pending.

  • Mine the pending job records into a new block by running:
curl http://127.0.0.1:5001/mine

You should see a JSON response with New block mined successfully and the full block details including both job records, the proof number, and the block's SHA-256 hash.

  • View the full blockchain by running:
curl http://127.0.0.1:5001/chain

You should see two blocks in the response. Block 0 is the genesis block with an empty transactions list. Block 1 contains both of your job records sealed inside it.

Seeing "Connection refused"?

Make sure your Flask server is still running in your other terminal window. You should see it logging incoming requests.

If the server stopped, switch to your original terminal and restart it with python3 app.py.

Help me debug a curl connection refused error with my Flask server.

Validate the chain and add a tamper endpoint

Your blockchain has a validation method that walks every block and checks that its hash is consistent. Time to confirm it works on a clean chain, then build a way to deliberately break it.

  • Verify the chain is valid by running:
curl http://127.0.0.1:5001/validate

You should see The blockchain is valid. All job records are authentic. in the response.

Now you will add an endpoint that deliberately changes data inside a mined block. This simulates someone trying to falsify a job record after it has been sealed into the chain.

  • In app.py, find the /validate route. Add the following /tamper endpoint below it, above the if __name__ == "__main__": block:
@app.route("/tamper", methods=["GET"])
def tamper():
    # Make sure there is at least one mined block to tamper with
    if len(blockchain.chain) < 2:
        return jsonify({"error": "Mine at least one block before tampering"}), 400

    # Grab block 1 and overwrite its real transactions with fake data
    target_block = blockchain.chain[1]
    original_data = target_block.transactions
    target_block.transactions = [{"contractor": "FAKE CONTRACTOR", "description": "Falsified record", "date": "2026-01-01"}]

    return jsonify({
        "message": "Block 1 has been tampered with!",
        "original_data": original_data,
        "tampered_data": target_block.transactions
    }), 200

What does this code do?

  • It grabs the block at index 1 (the first block after the genesis block) and replaces its transactions with completely fake data.
  • The block's stored hash was computed from the original data. By changing the data without recalculating the hash, the block becomes internally inconsistent.
  • The response shows both the original and tampered data so you can see exactly what changed.
  • Save app.py.

Check your other terminal. Flask auto-reloads the server when you save, which means the in-memory blockchain resets to just the genesis block. You will re-submit records in the next substep.

Server crashed instead of reloading?

A syntax error in app.py will cause the auto-reload to fail. Check the server terminal for the error message and fix the issue in your code.

Make sure the new /tamper route is placed between the /validate route and the if __name__ block, not inside another function.

Help me fix a Flask auto-reload crash after adding a new route.

✔️ Awesome, I've got everything!

Make sure you have saved app.py and the server has reloaded in your other terminal.

ⓧ I'd like to double check the full code

Here is the full app.py at this point.

import hashlib
from flask import Flask, jsonify, request, render_template, redirect, url_for

from blockchain import Blockchain

app = Flask(__name__)
blockchain = Blockchain()


@app.route("/jobs/new", methods=["POST"])
def new_job():
    data = request.get_json()

    required_fields = ["contractor", "description", "date"]
    if not all(field in data for field in required_fields):
        return jsonify({"error": "Missing fields. Required: contractor, description, date"}), 400

    blockchain.add_transaction(
        contractor=data["contractor"],
        description=data["description"],
        date=data["date"]
    )

    return jsonify({"message": "Job record added to pending transactions"}), 201


@app.route("/mine", methods=["GET"])
def mine():
    if not blockchain.pending_transactions:
        return jsonify({"message": "No pending job records to mine"}), 400

    block = blockchain.mine_block()

    return jsonify({
        "message": "New block mined successfully",
        "block": block.to_dict()
    }), 200


@app.route("/chain", methods=["GET"])
def get_chain():
    chain_data = [block.to_dict() for block in blockchain.chain]
    return jsonify({
        "chain": chain_data,
        "length": len(chain_data)
    }), 200


@app.route("/validate", methods=["GET"])
def validate():
    is_valid = blockchain.is_chain_valid()

    if is_valid:
        return jsonify({"message": "The blockchain is valid. All job records are authentic."}), 200
    else:
        return jsonify({"message": "INVALID! The blockchain has been tampered with."}), 200


@app.route("/tamper", methods=["GET"])
def tamper():
    if len(blockchain.chain) < 2:
        return jsonify({"error": "Mine at least one block before tampering"}), 400

    target_block = blockchain.chain[1]
    original_data = target_block.transactions
    target_block.transactions = [{"contractor": "FAKE CONTRACTOR", "description": "Falsified record", "date": "2026-01-01"}]

    return jsonify({
        "message": "Block 1 has been tampered with!",
        "original_data": original_data,
        "tampered_data": target_block.transactions
    }), 200


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5001, debug=True)
Break the chain and prove tamper detection

This is the key moment of the project. You are about to prove that changing even one field inside a mined block makes the entire chain fail validation.

Since saving app.py restarted the server, the blockchain is back to just the genesis block. You need to re-submit and mine before you can tamper.

  • Re-submit two job records and mine them by running these commands one at a time:
curl -X POST http://127.0.0.1:5001/jobs/new \
  -H "Content-Type: application/json" \
  -d '{"contractor": "Alice", "description": "Built authentication module", "date": "2026-04-15"}'
curl -X POST http://127.0.0.1:5001/jobs/new \
  -H "Content-Type: application/json" \
  -d '{"contractor": "Bob", "description": "Deployed staging server", "date": "2026-04-20"}'
curl http://127.0.0.1:5001/mine

You should see New block mined successfully again. Your chain now has the genesis block (index 0) and one mined block (index 1).

  • Confirm the chain is valid before tampering by running:
curl http://127.0.0.1:5001/validate

The response should confirm the chain is valid.

  • Now tamper with block 1 by running:
curl http://127.0.0.1:5001/tamper

You should see the original data (Alice and Bob's records) alongside the tampered data (FAKE CONTRACTOR).

  • Run validation one more time by running:
curl http://127.0.0.1:5001/validate

This time the response says INVALID! The blockchain has been tampered with. The same endpoint that returned valid seconds ago now catches the falsification.

Why does tampering break the chain?

Every block's hash is computed from its contents. When you changed block 1's transactions, its stored hash no longer matches a freshly recalculated hash of the new data. The validation method catches this immediately.

Even if a tamperer recalculated block 1's hash, block 2 (or the next block) stores a copy of block 1's original hash in its previous_hash field. That would now be a mismatch too. To cover their tracks, the tamperer would have to recalculate every block from the tampered one onward. The proof-of-work requirement makes that computationally impractical at scale.

Validation still says valid after tampering?

Make sure you hit /tamper before hitting /validate. The tamper endpoint modifies the in-memory chain, so the order matters.

If /tamper returned Mine at least one block before tampering, you need to submit job records and mine a block first. Re-run the POST and mine commands above.

Help me figure out why my blockchain validation still passes after tampering.

You have proven the core guarantee of blockchain: every block is cryptographically linked to the one before it, making tampering detectable. Next up, you will build a real-world deliverable verification system that fingerprints actual files and stores proof of their authenticity on the chain.

Build a Real-World Deliverable Verification System

In the previous step, you proved that blockchain makes tampering detectable. That is powerful, but the ledger only records text-based job descriptions so far.

What if someone submits an actual document (a PDF report, a design file, source code) and later needs to prove it has not been altered? You need a way to fingerprint real files and store those fingerprints on the chain. That is exactly how production document notarization services work.

In this step, get ready to:
  • Add deliverable submission and verification methods to your Blockchain class.
  • Build two new API endpoints that accept file uploads and compute SHA-256 fingerprints.
  • Test the full workflow by submitting a file, mining it, verifying the original, and proving that even a one-character change makes verification fail.
Add deliverable methods to the Blockchain class

The core idea behind file verification is that you never store the actual file on the blockchain. You store its SHA-256 fingerprint. If even a single byte of the file changes, the fingerprint changes completely.

  • In blockchain.py, find the add_transaction method. Copy the following code directly below it, before the proof_of_work method:
    def add_deliverable(self, contractor, description, file_hash):
        # Store the file fingerprint alongside the job record
        self.pending_transactions.append({
            "contractor": contractor,
            "description": description,
            "file_hash": file_hash,
            "type": "deliverable",
            "date": str(datetime.now())
        })

What does this code do?

This method works like add_transaction, but it also stores a file_hash field. The hash is the SHA-256 fingerprint of the actual file the contractor submitted.

The type field is set to "deliverable" so you can distinguish these records from plain job records later.

  • Save blockchain.py.

Now you need a method that searches the entire mined chain for a matching file fingerprint.

  • Directly below the add_deliverable method you just added, copy this code before proof_of_work:
    def verify_deliverable(self, file_hash):
        # Walk every block and every transaction looking for a matching fingerprint
        for block in self.chain:
            for transaction in block.transactions:
                if transaction.get("file_hash") == file_hash:
                    return {
                        "verified": True,
                        "block_index": block.index,
                        "timestamp": block.timestamp,
                        "contractor": transaction.get("contractor"),
                        "description": transaction.get("description")
                    }
        return None

What does this code do?

This method iterates through every block in the chain and every transaction inside each block. If it finds a transaction whose file_hash matches the one you are looking for, it returns proof: the block index, timestamp, and who submitted it.

If no match is found anywhere in the chain, it returns None. The file is unverified.

  • Save blockchain.py.

You will not see a visible change yet. The next substep wires these methods up to API endpoints you can test.

Seeing a syntax error in your terminal?

Flask's debug mode auto-reloads when you save. If it reports a syntax error, check your indentation. Both new methods must be indented at the same level as add_transaction (one level inside the Blockchain class).

Help me debug a syntax error in blockchain.py after adding add_deliverable and verify_deliverable methods.

✔️ Awesome, I've got everything!

Double check you have saved blockchain.py.

ⓧ I'd like to double check the full code

import hashlib
import json
from datetime import datetime


class Block:
    def __init__(self, index, timestamp, transactions, proof, previous_hash):
        self.index = index
        self.timestamp = timestamp
        self.transactions = transactions
        self.proof = proof
        self.previous_hash = previous_hash
        self.hash = self.calculate_hash()

    def calculate_hash(self):
        block_string = json.dumps({
            "index": self.index,
            "timestamp": self.timestamp,
            "transactions": self.transactions,
            "proof": self.proof,
            "previous_hash": self.previous_hash
        }, sort_keys=True)
        return hashlib.sha256(block_string.encode()).hexdigest()

    def to_dict(self):
        return {
            "index": self.index,
            "timestamp": self.timestamp,
            "transactions": self.transactions,
            "proof": self.proof,
            "previous_hash": self.previous_hash,
            "hash": self.hash
        }


class Blockchain:
    def __init__(self):
        self.chain = []
        self.pending_transactions = []
        self.create_genesis_block()

    def create_genesis_block(self):
        genesis_block = Block(0, str(datetime.now()), [], 1, "0")
        self.chain.append(genesis_block)

    def get_latest_block(self):
        return self.chain[-1]

    def add_transaction(self, contractor, description, date):
        self.pending_transactions.append({
            "contractor": contractor,
            "description": description,
            "date": date
        })

    def add_deliverable(self, contractor, description, file_hash):
        self.pending_transactions.append({
            "contractor": contractor,
            "description": description,
            "file_hash": file_hash,
            "type": "deliverable",
            "date": str(datetime.now())
        })

    def verify_deliverable(self, file_hash):
        for block in self.chain:
            for transaction in block.transactions:
                if transaction.get("file_hash") == file_hash:
                    return {
                        "verified": True,
                        "block_index": block.index,
                        "timestamp": block.timestamp,
                        "contractor": transaction.get("contractor"),
                        "description": transaction.get("description")
                    }
        return None

    def proof_of_work(self, previous_proof):
        new_proof = 1
        while True:
            hash_operation = hashlib.sha256(
                str(new_proof ** 2 - previous_proof ** 2).encode()
            ).hexdigest()
            if hash_operation[:4] == "0000":
                return new_proof
            new_proof += 1

    def hash(self, block):
        block_string = json.dumps({
            "index": block.index,
            "timestamp": block.timestamp,
            "transactions": block.transactions,
            "proof": block.proof,
            "previous_hash": block.previous_hash
        }, sort_keys=True)
        return hashlib.sha256(block_string.encode()).hexdigest()

    def mine_block(self):
        previous_block = self.get_latest_block()
        previous_proof = previous_block.proof
        proof = self.proof_of_work(previous_proof)
        previous_hash = self.hash(previous_block)

        block = Block(
            index=len(self.chain),
            timestamp=str(datetime.now()),
            transactions=self.pending_transactions,
            proof=proof,
            previous_hash=previous_hash
        )

        self.pending_transactions = []
        self.chain.append(block)
        return block

    def is_chain_valid(self):
        for i in range(1, len(self.chain)):
            current_block = self.chain[i]
            previous_block = self.chain[i - 1]

            if current_block.hash != current_block.calculate_hash():
                return False

            if current_block.previous_hash != self.hash(previous_block):
                return False

            hash_operation = hashlib.sha256(
                str(current_block.proof ** 2 - previous_block.proof ** 2).encode()
            ).hexdigest()
            if hash_operation[:4] != "0000":
                return False

        return True
Add the deliverable API endpoints

Your blockchain can now store and look up file fingerprints. The next piece is a pair of API endpoints that accept actual file uploads, compute the SHA-256 hash, and call the methods you just wrote.

  • In app.py, find the tamper function. Copy the following code below it, before the if __name__ == "__main__": line:
@app.route("/deliverables/submit", methods=["POST"])
def submit_deliverable():
    # Check that a file was included in the request
    if "file" not in request.files:
        return jsonify({"error": "No file provided. Send a file with the key 'file'"}), 400

    file = request.files["file"]
    contractor = request.form.get("contractor")
    description = request.form.get("description")

    if not contractor or not description:
        return jsonify({"error": "Missing fields. Required: contractor, description (as form data)"}), 400

    # Compute the SHA-256 fingerprint by reading the file in chunks
    sha256_hash = hashlib.sha256()
    while chunk := file.read(4096):
        sha256_hash.update(chunk)
    file_hash = sha256_hash.hexdigest()

    # Store the fingerprint on the blockchain
    blockchain.add_deliverable(
        contractor=contractor,
        description=description,
        file_hash=file_hash
    )

    return jsonify({
        "message": "Deliverable submitted to pending transactions",
        "file_hash": file_hash,
        "contractor": contractor
    }), 201

What does this code do?

This endpoint accepts a file upload along with a contractor name and description as form data. It reads the uploaded file in 4096-byte chunks to avoid loading huge files entirely into memory.

The while chunk := file.read(4096): line uses Python's walrus operator (:=) to read and assign in one expression. Each chunk is fed into the SHA-256 hash object. The final .hexdigest() produces the 64-character fingerprint that gets stored on the chain.

Why store the hash instead of the file?

A SHA-256 hash is always exactly 64 characters, no matter how large the original file is. Storing the hash on-chain keeps blocks small and fast to verify. Anyone can re-hash the original file later and compare it to the on-chain fingerprint.

  • Save app.py.

Now you need the verification endpoint that accepts a file, hashes it, and checks if that fingerprint exists anywhere on the chain.

  • Directly below the submit_deliverable function, copy this code (still above if __name__ == "__main__":):
@app.route("/deliverables/verify", methods=["POST"])
def verify_deliverable():
    # Check that a file was included in the request
    if "file" not in request.files:
        return jsonify({"error": "No file provided. Send a file with the key 'file'"}), 400

    file = request.files["file"]

    # Compute the SHA-256 fingerprint of the uploaded file
    sha256_hash = hashlib.sha256()
    while chunk := file.read(4096):
        sha256_hash.update(chunk)
    file_hash = sha256_hash.hexdigest()

    # Search the blockchain for a matching fingerprint
    result = blockchain.verify_deliverable(file_hash)

    if result:
        return jsonify({
            "message": "VERIFIED. This file is authenticated on the blockchain.",
            "file_hash": file_hash,
            "proof": result
        }), 200
    else:
        return jsonify({
            "message": "NOT FOUND. This file does not match any deliverable on the blockchain.",
            "file_hash": file_hash
        }), 404

What does this code do?

This endpoint hashes the uploaded file exactly the same way the submit endpoint does. It then calls blockchain.verify_deliverable(file_hash) to search the chain.

If a match is found, the response includes the proof: which block it was recorded in, when, and who submitted it. If no match exists, the API returns a 404 with a "NOT FOUND" message.

  • Save app.py.
  • Check your terminal where the Flask server is running. Flask's debug mode auto-reloads when you save. You should see a line like * Restarting with stat confirming the reload.

Server not reloading or showing an error?

Make sure both new route functions are placed above the if __name__ == "__main__": line. Code below that line only runs when you start the server, not when Flask processes requests.

Also check that the import hashlib line at the top of app.py is still there. The file hashing code depends on it.

Help me fix my Flask server after adding deliverable endpoints.

✔️ Awesome, I've got everything!

Double check you have saved app.py.

ⓧ I'd like to double check the full code

import hashlib
from flask import Flask, jsonify, request, render_template, redirect, url_for

from blockchain import Blockchain

app = Flask(__name__)
blockchain = Blockchain()


@app.route("/jobs/new", methods=["POST"])
def new_job():
    data = request.get_json()

    required_fields = ["contractor", "description", "date"]
    if not all(field in data for field in required_fields):
        return jsonify({"error": "Missing fields. Required: contractor, description, date"}), 400

    blockchain.add_transaction(
        contractor=data["contractor"],
        description=data["description"],
        date=data["date"]
    )

    return jsonify({"message": "Job record added to pending transactions"}), 201


@app.route("/mine", methods=["GET"])
def mine():
    if not blockchain.pending_transactions:
        return jsonify({"message": "No pending job records to mine"}), 400

    block = blockchain.mine_block()

    return jsonify({
        "message": "New block mined successfully",
        "block": block.to_dict()
    }), 200


@app.route("/chain", methods=["GET"])
def get_chain():
    chain_data = [block.to_dict() for block in blockchain.chain]
    return jsonify({
        "chain": chain_data,
        "length": len(chain_data)
    }), 200


@app.route("/validate", methods=["GET"])
def validate():
    is_valid = blockchain.is_chain_valid()

    if is_valid:
        return jsonify({"message": "The blockchain is valid. All job records are authentic."}), 200
    else:
        return jsonify({"message": "INVALID! The blockchain has been tampered with."}), 200


@app.route("/tamper", methods=["GET"])
def tamper():
    if len(blockchain.chain) < 2:
        return jsonify({"error": "Mine at least one block before tampering"}), 400

    target_block = blockchain.chain[1]
    original_data = target_block.transactions
    target_block.transactions = [{"contractor": "FAKE CONTRACTOR", "description": "Falsified record", "date": "2026-01-01"}]

    return jsonify({
        "message": "Block 1 has been tampered with!",
        "original_data": original_data,
        "tampered_data": target_block.transactions
    }), 200


@app.route("/deliverables/submit", methods=["POST"])
def submit_deliverable():
    if "file" not in request.files:
        return jsonify({"error": "No file provided. Send a file with the key 'file'"}), 400

    file = request.files["file"]
    contractor = request.form.get("contractor")
    description = request.form.get("description")

    if not contractor or not description:
        return jsonify({"error": "Missing fields. Required: contractor, description (as form data)"}), 400

    sha256_hash = hashlib.sha256()
    while chunk := file.read(4096):
        sha256_hash.update(chunk)
    file_hash = sha256_hash.hexdigest()

    blockchain.add_deliverable(
        contractor=contractor,
        description=description,
        file_hash=file_hash
    )

    return jsonify({
        "message": "Deliverable submitted to pending transactions",
        "file_hash": file_hash,
        "contractor": contractor
    }), 201


@app.route("/deliverables/verify", methods=["POST"])
def verify_deliverable():
    if "file" not in request.files:
        return jsonify({"error": "No file provided. Send a file with the key 'file'"}), 400

    file = request.files["file"]

    sha256_hash = hashlib.sha256()
    while chunk := file.read(4096):
        sha256_hash.update(chunk)
    file_hash = sha256_hash.hexdigest()

    result = blockchain.verify_deliverable(file_hash)

    if result:
        return jsonify({
            "message": "VERIFIED. This file is authenticated on the blockchain.",
            "file_hash": file_hash,
            "proof": result
        }), 200
    else:
        return jsonify({
            "message": "NOT FOUND. This file does not match any deliverable on the blockchain.",
            "file_hash": file_hash
        }), 404


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5001, debug=True)
Test the full deliverable verification workflow

Time to see file fingerprinting in action. You will submit a real file to the blockchain, mine it into a block, verify the original, and then prove that even a tiny change makes verification fail.

  • Open a separate terminal window. Make sure you are inside your blockchain-ledger folder.
  • Create a sample deliverable file by running this command:
echo "Q1 Project Report - Final Draft" > report.txt
  • Confirm the file exists by running:
ls

You should see report.txt listed alongside blockchain.py, app.py, and templates.

  • Submit the file to the blockchain by running this command:
curl -X POST -F "file=@report.txt" -F "contractor=Alice Johnson" -F "description=Q1 Project Report" http://127.0.0.1:5001/deliverables/submit

What does this command do?

  • -F "file=@report.txt" uploads the file as multipart form data. The @ tells curl to read the file contents.
  • -F "contractor=Alice Johnson" and -F "description=Q1 Project Report" send the metadata as form fields alongside the file.

You should see a JSON response with the message Deliverable submitted to pending transactions, along with a 64-character file_hash string. That is the SHA-256 fingerprint of your file.

  • Mine the pending deliverable into a block by running:
curl http://127.0.0.1:5001/mine

You should see New block mined successfully along with the full block details. Your deliverable's fingerprint is now permanently sealed in the chain.

  • Verify the original file against the blockchain by running:
curl -X POST -F "file=@report.txt" http://127.0.0.1:5001/deliverables/verify

You should see VERIFIED. This file is authenticated on the blockchain. along with the proof showing which block it was recorded in and who submitted it.

Now for the key test. Change a single character in the file and try to verify it again.

  • Overwrite the file with slightly different content by running:
echo "Q1 Project Report - Final Draft (revised)" > report.txt
  • Try to verify the modified file by running:
curl -X POST -F "file=@report.txt" http://127.0.0.1:5001/deliverables/verify

This time you should see NOT FOUND. This file does not match any deliverable on the blockchain. with a 404 status code.

Why does a tiny change break verification?

SHA-256 is designed so that even a single byte of change in the input produces a completely different hash. The original file and the modified file have entirely different fingerprints. Since the modified fingerprint was never recorded on the chain, it cannot be verified.

This is exactly how real document notarization services prove authenticity. If someone alters a deliverable after submission, the fingerprints will not match.

Getting a "Connection refused" error?

Make sure your Flask server is still running in the other terminal window. If it crashed after saving, check the error output for syntax issues in app.py or blockchain.py. Restart it by running python3 app.py.

Help me troubleshoot a connection error when testing the deliverable endpoints.

Your blockchain can now fingerprint real files and prove their authenticity. Next up, you will wrap this into a browser-based platform where anyone can upload documents through a web page instead of using curl.

Build the Web Verification Platform

Your deliverable verification system works, but right now the only way to use it is through curl commands in the terminal. A real verification platform needs a browser interface where anyone can upload files without touching a command line.

In this step, you will build a web-based front end for your blockchain. Flask includes a built-in templating engine called Jinja2 that lets you render HTML pages with dynamic data passed from Python. You will create an HTML page with upload forms and add new routes that handle the browser interactions behind the scenes.

In this step, get ready to:
  • Create an HTML template with forms for submitting and verifying deliverables.
  • Add web routes to Flask that render the template and handle form submissions.
  • Test the full browser workflow by uploading, verifying, and tampering with documents.
Create the HTML template

Flask looks for HTML files inside a folder called templates/. You already created this folder in Step 1. The template will contain two card sections: one form for submitting deliverables and one for verifying them.

  • Open templates/index.html in your code editor and paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Blockchain Verification Platform</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 2rem; }
        h1 { text-align: center; margin-bottom: 0.5rem; font-size: 1.8rem; }
        .subtitle { text-align: center; color: #666; margin-bottom: 2rem; }
        .container { max-width: 700px; margin: 0 auto; }
        .card { background: #fff; border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
        .card h2 { margin-bottom: 1rem; font-size: 1.3rem; }
        label { display: block; margin-bottom: 0.3rem; font-weight: 600; font-size: 0.9rem; }
        input[type="text"], input[type="file"] { width: 100%; padding: 0.5rem; margin-bottom: 1rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.95rem; }
        button { background: #2563eb; color: #fff; border: none; padding: 0.6rem 1.5rem; border-radius: 4px; font-size: 1rem; cursor: pointer; }
        button:hover { background: #1d4ed8; }
        .result { padding: 1rem; border-radius: 4px; margin-top: 1rem; font-size: 0.9rem; }
        .success { background: #dcfce7; border: 1px solid #86efac; }
        .error { background: #fee2e2; border: 1px solid #fca5a5; }
        .not-found { background: #fef3c7; border: 1px solid #fcd34d; }
        .hash { font-family: monospace; word-break: break-all; font-size: 0.85rem; color: #555; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Blockchain Verification Platform</h1>
        <p class="subtitle">Upload documents to record them on the blockchain or verify their authenticity.</p>

        <div class="card">
            <h2>Submit a Deliverable</h2>
            <form action="/web/submit" method="post" enctype="multipart/form-data">
                <label for="contractor">Contractor Name</label>
                <input type="text" id="contractor" name="contractor" required>

                <label for="description">Description</label>
                <input type="text" id="description" name="description" required>

                <label for="submit-file">File</label>
                <input type="file" id="submit-file" name="file" required>

                <button type="submit">Submit to Blockchain</button>
            </form>

            {% if submit_success %}
            <div class="result success">
                <strong>Recorded on the blockchain.</strong><br>
                Block #{{ submit_block_index }}<br>
                Block hash: <span class="hash">{{ submit_block_hash }}</span><br>
                File fingerprint: <span class="hash">{{ submit_file_hash }}</span>
            </div>
            {% endif %}

            {% if submit_error %}
            <div class="result error">{{ submit_error }}</div>
            {% endif %}
        </div>

        <div class="card">
            <h2>Verify a Deliverable</h2>
            <form action="/web/verify" method="post" enctype="multipart/form-data">
                <label for="verify-file">File</label>
                <input type="file" id="verify-file" name="file" required>

                <button type="submit">Verify Against Blockchain</button>
            </form>

            {% if verify_success %}
            <div class="result success">
                <strong>VERIFIED.</strong> This file is authentic.<br>
                Submitted by: {{ verify_result.contractor }}<br>
                Recorded in block #{{ verify_result.block_index }} at {{ verify_result.timestamp }}<br>
                File fingerprint: <span class="hash">{{ verify_file_hash }}</span>
            </div>
            {% endif %}

            {% if verify_not_found %}
            <div class="result not-found">
                <strong>NOT FOUND.</strong> This file does not match any deliverable on the blockchain.<br>
                File fingerprint: <span class="hash">{{ verify_file_hash }}</span>
            </div>
            {% endif %}

            {% if verify_error %}
            <div class="result error">{{ verify_error }}</div>
            {% endif %}
        </div>
    </div>
</body>
</html>

What does this template do?

The page has two card sections. The first card contains a "Submit a Deliverable" form with fields for the contractor name, a description, and a file upload. The second card has a "Verify a Deliverable" form with just a file upload.

Both forms use enctype="multipart/form-data", which tells the browser to transmit the uploaded file as binary data. Without this attribute, file uploads silently fail.

The {% if submit_success %} blocks are Jinja2 conditionals. They only render when your Python route passes a matching variable to render_template(). This is how results appear inline after a form submission.

  • Save templates/index.html.

You will not see a change yet. The template needs Flask routes to serve it, which you will add next.

✔️ Awesome, I've got everything!

Double check you have saved templates/index.html before moving on.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Blockchain Verification Platform</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 2rem; }
        h1 { text-align: center; margin-bottom: 0.5rem; font-size: 1.8rem; }
        .subtitle { text-align: center; color: #666; margin-bottom: 2rem; }
        .container { max-width: 700px; margin: 0 auto; }
        .card { background: #fff; border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
        .card h2 { margin-bottom: 1rem; font-size: 1.3rem; }
        label { display: block; margin-bottom: 0.3rem; font-weight: 600; font-size: 0.9rem; }
        input[type="text"], input[type="file"] { width: 100%; padding: 0.5rem; margin-bottom: 1rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.95rem; }
        button { background: #2563eb; color: #fff; border: none; padding: 0.6rem 1.5rem; border-radius: 4px; font-size: 1rem; cursor: pointer; }
        button:hover { background: #1d4ed8; }
        .result { padding: 1rem; border-radius: 4px; margin-top: 1rem; font-size: 0.9rem; }
        .success { background: #dcfce7; border: 1px solid #86efac; }
        .error { background: #fee2e2; border: 1px solid #fca5a5; }
        .not-found { background: #fef3c7; border: 1px solid #fcd34d; }
        .hash { font-family: monospace; word-break: break-all; font-size: 0.85rem; color: #555; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Blockchain Verification Platform</h1>
        <p class="subtitle">Upload documents to record them on the blockchain or verify their authenticity.</p>

        <div class="card">
            <h2>Submit a Deliverable</h2>
            <form action="/web/submit" method="post" enctype="multipart/form-data">
                <label for="contractor">Contractor Name</label>
                <input type="text" id="contractor" name="contractor" required>

                <label for="description">Description</label>
                <input type="text" id="description" name="description" required>

                <label for="submit-file">File</label>
                <input type="file" id="submit-file" name="file" required>

                <button type="submit">Submit to Blockchain</button>
            </form>

            {% if submit_success %}
            <div class="result success">
                <strong>Recorded on the blockchain.</strong><br>
                Block #{{ submit_block_index }}<br>
                Block hash: <span class="hash">{{ submit_block_hash }}</span><br>
                File fingerprint: <span class="hash">{{ submit_file_hash }}</span>
            </div>
            {% endif %}

            {% if submit_error %}
            <div class="result error">{{ submit_error }}</div>
            {% endif %}
        </div>

        <div class="card">
            <h2>Verify a Deliverable</h2>
            <form action="/web/verify" method="post" enctype="multipart/form-data">
                <label for="verify-file">File</label>
                <input type="file" id="verify-file" name="file" required>

                <button type="submit">Verify Against Blockchain</button>
            </form>

            {% if verify_success %}
            <div class="result success">
                <strong>VERIFIED.</strong> This file is authentic.<br>
                Submitted by: {{ verify_result.contractor }}<br>
                Recorded in block #{{ verify_result.block_index }} at {{ verify_result.timestamp }}<br>
                File fingerprint: <span class="hash">{{ verify_file_hash }}</span>
            </div>
            {% endif %}

            {% if verify_not_found %}
            <div class="result not-found">
                <strong>NOT FOUND.</strong> This file does not match any deliverable on the blockchain.<br>
                File fingerprint: <span class="hash">{{ verify_file_hash }}</span>
            </div>
            {% endif %}

            {% if verify_error %}
            <div class="result error">{{ verify_error }}</div>
            {% endif %}
        </div>
    </div>
</body>
</html>
Add the web routes to app.py

Your existing API routes return raw JSON. The new web routes will do something different: they render the HTML template and pass data into it. They also auto-mine blocks after submission so the user never has to call /mine manually.

  • In app.py, find the line that says blockchain = Blockchain().
  • Below that line (and above the first @app.route decorator), add the following code for the homepage and the submit route:
# --- Web Routes ---

@app.route("/")
def home():
    return render_template("index.html")


@app.route("/web/submit", methods=["POST"])
def web_submit():
    file = request.files.get("file")
    contractor = request.form.get("contractor", "").strip()
    description = request.form.get("description", "").strip()

    if not file or not contractor or not description:
        return render_template("index.html", submit_error="All fields are required.")

    sha256_hash = hashlib.sha256()
    while chunk := file.read(4096):
        sha256_hash.update(chunk)
    file_hash = sha256_hash.hexdigest()

    blockchain.add_deliverable(
        contractor=contractor,
        description=description,
        file_hash=file_hash
    )
    block = blockchain.mine_block()

    return render_template(
        "index.html",
        submit_success=True,
        submit_file_hash=file_hash,
        submit_block_index=block.index,
        submit_block_hash=block.hash
    )

What does this code do?

  • The / route serves the homepage by rendering index.html with no extra data.
  • The /web/submit route reads the uploaded file and form fields from the browser request. It computes the file's SHA-256 hash in 4096-byte chunks, adds the deliverable to the blockchain, and immediately mines a new block.
  • After mining, it re-renders index.html with variables like submit_success=True and submit_file_hash. These are the variables the Jinja2 conditionals in your template check for.
  • You will not see a change yet. The verify route comes next.
  • Directly below the web_submit function you just added, paste the verify route:
@app.route("/web/verify", methods=["POST"])
def web_verify():
    file = request.files.get("file")

    if not file:
        return render_template("index.html", verify_error="Please select a file to verify.")

    sha256_hash = hashlib.sha256()
    while chunk := file.read(4096):
        sha256_hash.update(chunk)
    file_hash = sha256_hash.hexdigest()

    result = blockchain.verify_deliverable(file_hash)

    if result:
        return render_template(
            "index.html",
            verify_success=True,
            verify_file_hash=file_hash,
            verify_result=result
        )
    else:
        return render_template(
            "index.html",
            verify_not_found=True,
            verify_file_hash=file_hash
        )

What does this code do?

  • The /web/verify route hashes the uploaded file and calls blockchain.verify_deliverable() to search the mined chain for a matching fingerprint.
  • If the hash is found, it renders the page with verify_success=True and the full proof (who submitted it, which block, and when). If not found, it passes verify_not_found=True so the template shows a warning instead.
  • Finally, add a section comment above your existing API routes to keep the file organized. Find the line that says @app.route("/jobs/new", methods=["POST"]).
  • Add this comment on a new line directly above it:
# --- API Routes ---
  • Save app.py.

Not sure where to place the new routes?

The three new functions (home, web_submit, web_verify) must go between blockchain = Blockchain() and the existing @app.route("/jobs/new"...) decorator. Use the double-check tab below to compare your full file.

Help me figure out where to place the web routes in app.py.

✔️ Awesome, I've got everything!

Double check you have saved app.py before moving on.

ⓧ I'd like to double check the full code

import hashlib
from flask import Flask, jsonify, request, render_template, redirect, url_for

from blockchain import Blockchain

app = Flask(__name__)
blockchain = Blockchain()


# --- Web Routes ---

@app.route("/")
def home():
    return render_template("index.html")


@app.route("/web/submit", methods=["POST"])
def web_submit():
    file = request.files.get("file")
    contractor = request.form.get("contractor", "").strip()
    description = request.form.get("description", "").strip()

    if not file or not contractor or not description:
        return render_template("index.html", submit_error="All fields are required.")

    sha256_hash = hashlib.sha256()
    while chunk := file.read(4096):
        sha256_hash.update(chunk)
    file_hash = sha256_hash.hexdigest()

    blockchain.add_deliverable(
        contractor=contractor,
        description=description,
        file_hash=file_hash
    )
    block = blockchain.mine_block()

    return render_template(
        "index.html",
        submit_success=True,
        submit_file_hash=file_hash,
        submit_block_index=block.index,
        submit_block_hash=block.hash
    )


@app.route("/web/verify", methods=["POST"])
def web_verify():
    file = request.files.get("file")

    if not file:
        return render_template("index.html", verify_error="Please select a file to verify.")

    sha256_hash = hashlib.sha256()
    while chunk := file.read(4096):
        sha256_hash.update(chunk)
    file_hash = sha256_hash.hexdigest()

    result = blockchain.verify_deliverable(file_hash)

    if result:
        return render_template(
            "index.html",
            verify_success=True,
            verify_file_hash=file_hash,
            verify_result=result
        )
    else:
        return render_template(
            "index.html",
            verify_not_found=True,
            verify_file_hash=file_hash
        )


# --- API Routes ---

@app.route("/jobs/new", methods=["POST"])
def new_job():
    data = request.get_json()

    required_fields = ["contractor", "description", "date"]
    if not all(field in data for field in required_fields):
        return jsonify({"error": "Missing fields. Required: contractor, description, date"}), 400

    blockchain.add_transaction(
        contractor=data["contractor"],
        description=data["description"],
        date=data["date"]
    )

    return jsonify({"message": "Job record added to pending transactions"}), 201


@app.route("/mine", methods=["GET"])
def mine():
    if not blockchain.pending_transactions:
        return jsonify({"message": "No pending job records to mine"}), 400

    block = blockchain.mine_block()

    return jsonify({
        "message": "New block mined successfully",
        "block": block.to_dict()
    }), 200


@app.route("/chain", methods=["GET"])
def get_chain():
    chain_data = [block.to_dict() for block in blockchain.chain]
    return jsonify({
        "chain": chain_data,
        "length": len(chain_data)
    }), 200


@app.route("/validate", methods=["GET"])
def validate():
    is_valid = blockchain.is_chain_valid()

    if is_valid:
        return jsonify({"message": "The blockchain is valid. All job records are authentic."}), 200
    else:
        return jsonify({"message": "INVALID! The blockchain has been tampered with."}), 200


@app.route("/tamper", methods=["GET"])
def tamper():
    if len(blockchain.chain) < 2:
        return jsonify({"error": "Mine at least one block before tampering"}), 400

    target_block = blockchain.chain[1]
    original_data = target_block.transactions
    target_block.transactions = [{"contractor": "FAKE CONTRACTOR", "description": "Falsified record", "date": "2026-01-01"}]

    return jsonify({
        "message": "Block 1 has been tampered with!",
        "original_data": original_data,
        "tampered_data": target_block.transactions
    }), 200


@app.route("/deliverables/submit", methods=["POST"])
def submit_deliverable():
    if "file" not in request.files:
        return jsonify({"error": "No file provided. Send a file with the key 'file'"}), 400

    file = request.files["file"]
    contractor = request.form.get("contractor")
    description = request.form.get("description")

    if not contractor or not description:
        return jsonify({"error": "Missing fields. Required: contractor, description (as form data)"}), 400

    sha256_hash = hashlib.sha256()
    while chunk := file.read(4096):
        sha256_hash.update(chunk)
    file_hash = sha256_hash.hexdigest()

    blockchain.add_deliverable(
        contractor=contractor,
        description=description,
        file_hash=file_hash
    )

    return jsonify({
        "message": "Deliverable submitted to pending transactions",
        "file_hash": file_hash,
        "contractor": contractor
    }), 201


@app.route("/deliverables/verify", methods=["POST"])
def verify_deliverable():
    if "file" not in request.files:
        return jsonify({"error": "No file provided. Send a file with the key 'file'"}), 400

    file = request.files["file"]

    sha256_hash = hashlib.sha256()
    while chunk := file.read(4096):
        sha256_hash.update(chunk)
    file_hash = sha256_hash.hexdigest()

    result = blockchain.verify_deliverable(file_hash)

    if result:
        return jsonify({
            "message": "VERIFIED. This file is authenticated on the blockchain.",
            "file_hash": file_hash,
            "proof": result
        }), 200
    else:
        return jsonify({
            "message": "NOT FOUND. This file does not match any deliverable on the blockchain.",
            "file_hash": file_hash
        }), 404


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5001, debug=True)
Test the full browser workflow

Everything is wired up. Time to restart the server and see your verification platform in action.

  • In your terminal, stop the running Flask server by pressing Ctrl+C.
  • Restart the server by running:
python3 app.py
  • You should see output including Running on http://127.0.0.1:5001.

Seeing an ImportError or SyntaxError?

Check that both app.py and templates/index.html are saved. A common cause is a missing closing parenthesis in one of the render_template() calls. Compare your code to the double-check tab above.

Help me debug why my Flask server won't start after adding the web routes.

Before testing in the browser, create a sample document to upload. Open a new terminal window (keep Flask running in the first one) and run:

  • Create a sample report file by running this command:
echo "Project report: Website redesign completed on schedule." > report.txt
  • Open your browser and go to http://127.0.0.1:5001.

You should see the Blockchain Verification Platform page with two white cards: one for submitting deliverables and one for verifying them.

  • In the Submit a Deliverable card, enter a name in the Contractor Name field (e.g. Alice Chen).
  • Enter a description in the Description field (e.g. Website redesign final report).
  • Click **Choose File** and select the report.txt file you just created.
  • Click Submit to Blockchain.

A green success box should appear below the form showing the block number, the block hash, and the file fingerprint. The block was automatically mined for you.

  • Now scroll down to the Verify a Deliverable card.
  • Click Choose File and select the same report.txt file.
  • Click Verify Against Blockchain.

A green "VERIFIED" box should appear confirming the file is authentic, showing who submitted it and which block it was recorded in.

Now test what happens with a tampered file.

  • In your second terminal, create a slightly modified version of the report by running:
echo "Project report: Website redesign completed ahead of schedule." > report-modified.txt
  • Back in the browser, use the Verify a Deliverable form to upload report-modified.txt.
  • Click Verify Against Blockchain.

A yellow "NOT FOUND" warning should appear. Even though only one word changed, the SHA-256 fingerprint is completely different, so the blockchain has no record of this version.

Why does one word change everything?

SHA-256 is designed so that any change to the input, no matter how small, produces a completely different hash. Changing "on" to "ahead of" flips roughly half the bits in the output. This property is called the avalanche effect, and it is what makes forgery impossible without detection.

Page says Not Found or 405 Method Not Allowed?

If the homepage shows a "Not Found" error, make sure you restarted Flask after saving your changes. Press Ctrl+C and run python3 app.py again.

If clicking a button shows "405 Method Not Allowed", check that your /web/submit and /web/verify routes include methods=["POST"] in their decorators.

Help me fix my blockchain web platform. I'm getting errors when I try to submit or verify files.

You now have a fully working blockchain-powered verification platform running in your browser. Anyone can upload a document, have it fingerprinted and sealed onto the chain, and later prove its authenticity. Next up, take on the Secret Mission to add digital signatures to your ledger.

Secret mission

Add Digital Signatures with HMAC

Your ledger records what was done and when. But it cannot prove who recorded it. Can you add digital signatures using HMAC so every job record is cryptographically tied to the contractor who submitted it?

Before you go

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Flask dev server running on port 5001.
  • Python virtual environment (venv folder inside blockchain-ledger).
  • Project files: blockchain.py, app.py, and templates/index.html.
  • Flask package (installed inside the virtual environment).

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment with the blockchain further.

  • Your Flask server stays running in the terminal.
  • Your virtual environment stays activated.
  • All project files remain on your Desktop.

Pause - I'll come back to this later

Shut down running processes to free up your terminal, but keep all project files so you can pick up where you left off.

  • In the terminal where Flask is running, press Ctrl+C to stop the server.
  • Deactivate your virtual environment by running:
deactivate
  • When you come back, navigate to your blockchain-ledger folder, reactivate the virtual environment with source venv/bin/activate, and run python3 app.py to start the server again.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • In the terminal where Flask is running, press Ctrl+C to stop the server.
  • Deactivate your virtual environment by running:
deactivate
  • Navigate to your Desktop and delete the entire project folder by running:
cd ~/Desktop
rm -rf blockchain-ledger
  • This removes the virtual environment, all project files, and the installed Flask package.

Mission accomplished

That's a Wrap!

Nice work! You just built a blockchain-powered job verification platform from scratch in Python. From cryptographic hashing to a browser-based verification portal, you now understand the full stack behind tamper-proof record keeping.

You've learned how to:

  • Built a blockchain from scratch with SHA-256 hashing, proof-of-work mining, and chain validation that detects any tampering attempt.
  • Created a Flask REST API to submit job records, mine blocks, view the chain, and verify integrity.
  • Built a web-based verification platform where users upload real documents through a browser and get blockchain-backed proof of authenticity.
  • Secret Mission: Added HMAC-based digital signatures to prove who submitted each job record.

Ready to quiz yourself?