diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..26c0388
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,6 @@
+# Contributing
+
+Welcome! We are happy to have your help with this project.
+
+Note that all contributions will be under our
+[license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
diff --git a/README.md b/README.md
index f2ff62f..8ae5555 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,11 @@
 # Image Server
 
+Remaining:
+- parameterize bucket for test server
+- try American Cloud
+- dockerize
+
+- pretty-up the client, show thumbnail
 
 ## setup
 
@@ -28,8 +34,10 @@ node server.js
 
 ## test
 
-```
-curl -X POST -F "image=@./test.png" http://localhost:3000/image
+```shell
+# run this first command in a directory where `npm install did-jwt` has been run
+CODE='OWNER_DID="did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F"; OWNER_PRIVATE_KEY_HEX="2b6472c026ec2aa2c4235c994a63868fc9212d18b58f6cbfe861b52e71330f5b"; didJwt = require("did-jwt"); didJwt.createJWT({ exp: Math.floor(Date.now() / 1000) + 60, iat: Math.floor(Date.now() / 1000), iss: OWNER_DID }, { issuer: OWNER_DID, signer: didJwt.SimpleSigner(OWNER_PRIVATE_KEY_HEX) }).then(console.log)'
+JWT=`node -e "$CODE"`; curl -X POST -H "Authorization: Bearer $JWT" -F "image=@./test.png" http://localhost:3001/image
 ```
 
 ## deploy to prod first time
diff --git a/package.json b/package.json
index a894dab..e5f8c6b 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
     "dotenv": "^16.4.5",
     "ethr-did-resolver": "^10.1.5",
     "express": "^4.18.2",
+    "luxon": "^3.4.4",
     "multer": "1.4.5-lts.1",
     "sqlite3": "^5.1.7"
   },
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 47959ab..5ca5746 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,6 +29,9 @@ dependencies:
   express:
     specifier: ^4.18.2
     version: 4.18.2
+  luxon:
+    specifier: ^3.4.4
+    version: 3.4.4
   multer:
     specifier: 1.4.5-lts.1
     version: 1.4.5-lts.1
@@ -2241,6 +2244,11 @@ packages:
       yallist: 4.0.0
     dev: false
 
+  /luxon@3.4.4:
+    resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==}
+    engines: {node: '>=12'}
+    dev: false
+
   /make-fetch-happen@9.1.0:
     resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==}
     engines: {node: '>= 10'}
diff --git a/server.js b/server.js
index 3c4d41b..41a8904 100644
--- a/server.js
+++ b/server.js
@@ -6,6 +6,7 @@ const { Resolver } = require('did-resolver');
 const express = require('express');
 const { getResolver } = require('ethr-did-resolver');
 const fs = require('fs');
+const { DateTime } = require('luxon');
 const multer = require('multer');
 const path = require('path');
 const sqlite3 = require('sqlite3').verbose();
@@ -15,7 +16,7 @@ require('dotenv').config()
 const app = express();
 app.use(cors());
 
-const port = process.env.PORT || 3000;
+const port = process.env.PORT || 3001;
 // file name also referenced in flyway.conf and potentially in .env files or in environment variables
 const dbFile = process.env.SQLITE_FILE || './image-db.sqlite';
 
@@ -34,6 +35,8 @@ const db = new sqlite3.Database(dbFile, (err) => {
   }
 });
 
+const endorserApiUrl = process.env.ENDORSER_API_URL || 'http://localhost:3000';
+
 // Configure AWS
 const s3Client = new S3Client({
   region: process.env.AWS_REGION,
@@ -56,12 +59,81 @@ app.post('/image', uploadMulter.single('image'), async (req, res) => {
       return res.status(401).send(JSON.stringify({ success: false, message: 'Missing "Bearer JWT" in Authorization header.'}));
     }
     const jwt = auth.substring('Bearer '.length);
-    const verified = await didJwt.verifyJWT(jwt, {resolver});
-    if (!verified) {
-      return res.status(401).send(JSON.stringify({ success: false, message: 'Got invalid JWT in Authorization header.'}));
+    const verified = await didJwt.verifyJWT(jwt, { resolver });
+    if (!verified.verified) {
+      const errorTime = new Date().toISOString();
+      console.log(errorTime, 'Got invalid JWT in Authorization header:', verified);
+      return res.status(401).send(JSON.stringify({ success: false, message: 'Got invalid JWT in Authorization header. See server logs at ' + errorTime }));
     }
     const issuerDid = verified.issuer;
 
+    // Check the user's limits, first from the DB and then from the server
+    let limitPerWeek = await new Promise((resolve, reject) => {
+      db.get(
+        'SELECT per_week FROM user WHERE did = ?',
+        [ issuerDid ],
+        (dbErr, row) => {
+          if (dbErr) {
+            console.error('Error getting user record from database:', dbErr)
+            // may not matter, so continue
+          }
+          resolve(row?.per_week);
+        }
+      );
+    });
+    if (limitPerWeek == null) {
+      const headers = {
+        'Authorization': `Bearer ${jwt}`,
+        'Content-Type': 'application/json'
+      }
+      const response = await fetch(endorserApiUrl + '/api/report/rateLimits', { headers });
+      if (response.status !== 200) {
+        console.error("Got bad response of", response.status, "when checking rate limits for", issuerDid);
+        return res.status(400).send(JSON.stringify({ success: false, message: 'Got bad status of ' + response.status + ' when checking limits with endorser server. Verify that the account exists and that the JWT works for that server.'}));
+      } else {
+        const body = await response.json();
+        limitPerWeek = body.maxClaimsPerWeek
+
+        await new Promise((resolve, reject) => {
+          db.run(
+            'INSERT INTO user (did, per_week) VALUES (?, ?)',
+            [issuerDid, limitPerWeek],
+            (dbErr) => {
+              if (dbErr) {
+                console.error("Error inserting user record for", issuerDid, "into database:", dbErr);
+                // we can continue... it just means we'll check the endorser server again next time
+              }
+              resolve();
+            }
+          );
+        });
+      }
+    }
+
+    if (limitPerWeek == null) {
+      return res.status(400).send(JSON.stringify({ success: false, message: 'Unable to determine rate limits for this user. Verify that the account exists and that the JWT works for that server.' }));
+    }
+
+    // check the user's claims so far this week
+    const startOfWeekDate = DateTime.utc().startOf('week') // luxon weeks start on Mondays
+    const startOfWeekString = startOfWeekDate.toISO()
+    let imagesCount = await new Promise((resolve, reject) => {
+      db.get(
+        'SELECT COUNT(*) AS week_count FROM image WHERE did = ? AND time >= ?',
+        [issuerDid, startOfWeekString],
+        (dbErr, row) => {
+          if (dbErr) {
+            console.error(currentDate, "Error counting records for", issuerDid, "into database:", dbErr);
+            // we can continue... it just means we'll check the endorser server again next time
+          }
+          resolve(row?.week_count);
+        }
+      );
+    });
+    if (imagesCount >= limitPerWeek) {
+      return res.status(400).send(JSON.stringify({ success: false, message: 'You have reached your weekly limit of ' + limitPerWeek + ' images.' }));
+    }
+
     // Read the file from the temporary location
     fs.readFile(req.file.path, async (err, data) => {
       if (err) throw err; // Handle error
@@ -85,19 +157,26 @@ app.post('/image', uploadMulter.single('image'), async (req, res) => {
         const currentDate = new Date().toISOString();
         const localFile = req.file.path.startsWith(uploadDir + '/') ? req.file.path.substring(uploadDir.length + 1) : req.file.path;
         const finalUrl = `https://${bucketName}.s3.amazonaws.com/${fileName}`;
-        await db.run('INSERT INTO image (time, did, local_file, size, final_file, url) VALUES (?, ?, ?, ?, ?, ?)', [
-          currentDate,
-          issuerDid,
-          localFile,
-          req.file.size,
-          fileName,
-          finalUrl
-        ], (dbErr) => {
-          if (dbErr) {
-            console.error(currentDate, "Error inserting record from", issuerDid, "into database:", dbErr);
-            // don't continue because then we'll have storage we cannot track (and potentially limit)
-            throw dbErr;
-          }
+        await new Promise((resolve, reject) => {
+          db.run(
+            'INSERT INTO image (time, did, local_file, size, final_file, url) VALUES (?, ?, ?, ?, ?, ?)',
+            [
+              currentDate,
+              issuerDid,
+              localFile,
+              req.file.size,
+              fileName,
+              finalUrl
+            ],
+            (dbErr) => {
+              if (dbErr) {
+                console.error(currentDate, "Error inserting record from", issuerDid, "into database:", dbErr);
+                // don't continue because then we'll have storage we cannot track (and potentially limit)
+                reject(dbErr);
+              }
+              resolve();
+            }
+          );
         });
 
         // send to AWS
diff --git a/sql/migrations/V1__Create_image_table.sql b/sql/migrations/V1__Create_image_table.sql
index fdf5470..55db414 100644
--- a/sql/migrations/V1__Create_image_table.sql
+++ b/sql/migrations/V1__Create_image_table.sql
@@ -1,6 +1,6 @@
 
 CREATE TABLE image (
-  time TEXT NOT NULL,
+  time TEXT NOT NULL, -- ISO 8601 @ UTC, eg 2019-01-01T00:00:00Z
   did TEXT NOT NULL,
   local_file TEXT NOT NULL,
   size INTEGER NOT NULL,
@@ -8,6 +8,13 @@ CREATE TABLE image (
   url TEXT NOT NULL
 );
 
-CREATE INDEX image_date ON image(time);
+CREATE INDEX image_time ON image(time);
 CREATE INDEX image_did ON image(did);
-CREATE INDEX image_finalFile ON image(final_file);
+CREATE INDEX image_final_file ON image(final_file);
+
+CREATE TABLE user (
+  did TEXT NOT NULL,
+  per_week INTEGER NOT NULL
+);
+
+CREATE INDEX user_did ON user(did);