diff --git a/briar-android-tests/src/test/java/org/briarproject/PrivateGroupManagerTest.java b/briar-android-tests/src/test/java/org/briarproject/PrivateGroupManagerTest.java
index 38ace43a3eae587d873785d1c7175185b17b1706..10d4280f41dcdc6634c535bd02d0ccf56abae2b3 100644
--- a/briar-android-tests/src/test/java/org/briarproject/PrivateGroupManagerTest.java
+++ b/briar-android-tests/src/test/java/org/briarproject/PrivateGroupManagerTest.java
@@ -27,7 +27,6 @@ import org.briarproject.api.privategroup.JoinMessageHeader;
 import org.briarproject.api.privategroup.PrivateGroup;
 import org.briarproject.api.privategroup.PrivateGroupFactory;
 import org.briarproject.api.privategroup.PrivateGroupManager;
-import org.briarproject.api.privategroup.invitation.GroupInvitationManager;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.MessageId;
@@ -94,8 +93,6 @@ public class PrivateGroupManagerTest extends BriarIntegrationTest {
 	PrivateGroupFactory privateGroupFactory;
 	@Inject
 	GroupMessageFactory groupMessageFactory;
-	@Inject
-	GroupInvitationManager groupInvitationManager;
 
 	// objects accessed from background threads need to be volatile
 	private volatile Waiter validationWaiter;
diff --git a/briar-android-tests/src/test/java/org/briarproject/PrivateGroupManagerTestComponent.java b/briar-android-tests/src/test/java/org/briarproject/PrivateGroupManagerTestComponent.java
index 68191f4f87b7099a9723749fb5d12184a050ab6a..08bdfd8a6b0dc1951a2a5ae0aacfa8ea46334fbc 100644
--- a/briar-android-tests/src/test/java/org/briarproject/PrivateGroupManagerTestComponent.java
+++ b/briar-android-tests/src/test/java/org/briarproject/PrivateGroupManagerTestComponent.java
@@ -17,6 +17,7 @@ import org.briarproject.identity.IdentityModule;
 import org.briarproject.lifecycle.LifecycleModule;
 import org.briarproject.messaging.MessagingModule;
 import org.briarproject.privategroup.PrivateGroupModule;
+import org.briarproject.privategroup.invitation.GroupInvitationModule;
 import org.briarproject.properties.PropertiesModule;
 import org.briarproject.sharing.SharingModule;
 import org.briarproject.sync.SyncModule;
@@ -40,6 +41,7 @@ import dagger.Component;
 		EventModule.class,
 		MessagingModule.class,
 		PrivateGroupModule.class,
+		GroupInvitationModule.class,
 		IdentityModule.class,
 		LifecycleModule.class,
 		PropertiesModule.class,
diff --git a/briar-android/src/org/briarproject/android/AndroidComponent.java b/briar-android/src/org/briarproject/android/AndroidComponent.java
index a436f9430fc9dc9d0e6b3582b8ed87f928160220..0a8feb91110a925c60cc88d00cc3319d13d1c844 100644
--- a/briar-android/src/org/briarproject/android/AndroidComponent.java
+++ b/briar-android/src/org/briarproject/android/AndroidComponent.java
@@ -20,7 +20,6 @@ import org.briarproject.api.event.EventBus;
 import org.briarproject.api.feed.FeedManager;
 import org.briarproject.api.forum.ForumManager;
 import org.briarproject.api.forum.ForumSharingManager;
-import org.briarproject.api.identity.AuthorFactory;
 import org.briarproject.api.identity.IdentityManager;
 import org.briarproject.api.introduction.IntroductionManager;
 import org.briarproject.api.invitation.InvitationTaskFactory;
@@ -37,6 +36,7 @@ import org.briarproject.api.plugins.PluginManager;
 import org.briarproject.api.privategroup.GroupMessageFactory;
 import org.briarproject.api.privategroup.PrivateGroupFactory;
 import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationFactory;
 import org.briarproject.api.privategroup.invitation.GroupInvitationManager;
 import org.briarproject.api.settings.SettingsManager;
 import org.briarproject.api.system.Clock;
@@ -68,8 +68,6 @@ public interface AndroidComponent extends CoreEagerSingletons {
 
 	DatabaseConfig databaseConfig();
 
-	AuthorFactory authFactory();
-
 	ReferenceManager referenceMangager();
 
 	@DatabaseExecutor
@@ -99,6 +97,8 @@ public interface AndroidComponent extends CoreEagerSingletons {
 
 	PrivateGroupManager privateGroupManager();
 
+	GroupInvitationFactory groupInvitationFactory();
+
 	GroupInvitationManager groupInvitationManager();
 
 	PrivateGroupFactory privateGroupFactory();
diff --git a/briar-android/src/org/briarproject/android/contact/ConversationActivity.java b/briar-android/src/org/briarproject/android/contact/ConversationActivity.java
index 849a8222f5a352194ab4341a17ec76a7370ce1af..c8eb36da9d9754558ff07256ddfac244fde64690 100644
--- a/briar-android/src/org/briarproject/android/contact/ConversationActivity.java
+++ b/briar-android/src/org/briarproject/android/contact/ConversationActivity.java
@@ -898,7 +898,7 @@ public class ConversationActivity extends BriarActivity
 	@DatabaseExecutor
 	private void respondToGroupRequest(SessionId id, boolean accept)
 			throws DbException {
-		groupInvitationManager.respondToInvitation(id, accept);
+		groupInvitationManager.respondToInvitation(contactId, id, accept);
 	}
 
 	private void introductionResponseError() {
diff --git a/briar-android/src/org/briarproject/android/privategroup/creation/BaseGroupInviteActivity.java b/briar-android/src/org/briarproject/android/privategroup/creation/BaseGroupInviteActivity.java
index 44c57cb14392a6e28a1d49c25580c43e42b684b5..895f6188f7f83a81404066958d1299c0caf5f50d 100644
--- a/briar-android/src/org/briarproject/android/privategroup/creation/BaseGroupInviteActivity.java
+++ b/briar-android/src/org/briarproject/android/privategroup/creation/BaseGroupInviteActivity.java
@@ -1,7 +1,6 @@
 package org.briarproject.android.privategroup.creation;
 
 import android.os.Bundle;
-import android.widget.Toast;
 
 import org.briarproject.R;
 import org.briarproject.android.controller.handler.UiResultExceptionHandler;
@@ -16,7 +15,6 @@ import java.util.Collection;
 
 import javax.inject.Inject;
 
-import static android.widget.Toast.LENGTH_SHORT;
 import static org.briarproject.api.privategroup.PrivateGroupConstants.MAX_GROUP_INVITATION_MSG_LENGTH;
 
 public abstract class BaseGroupInviteActivity
@@ -68,9 +66,6 @@ public abstract class BaseGroupInviteActivity
 				new UiResultExceptionHandler<Void, DbException>(this) {
 					@Override
 					public void onResultUi(Void result) {
-						Toast.makeText(BaseGroupInviteActivity.this,
-								"Inviting members is not yet implemented",
-								LENGTH_SHORT).show();
 						setResult(RESULT_OK);
 						supportFinishAfterTransition();
 					}
diff --git a/briar-android/src/org/briarproject/android/privategroup/creation/CreateGroupController.java b/briar-android/src/org/briarproject/android/privategroup/creation/CreateGroupController.java
index da1a00a0d1275559ebdb13272a91d626ef27fffb..cc46f6e47a2cd675f6a69cef72cd6f3ae58083ce 100644
--- a/briar-android/src/org/briarproject/android/privategroup/creation/CreateGroupController.java
+++ b/briar-android/src/org/briarproject/android/privategroup/creation/CreateGroupController.java
@@ -4,10 +4,12 @@ import org.briarproject.android.controller.DbController;
 import org.briarproject.android.controller.handler.ResultExceptionHandler;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.nullsafety.NotNullByDefault;
 import org.briarproject.api.sync.GroupId;
 
 import java.util.Collection;
 
+@NotNullByDefault
 public interface CreateGroupController extends DbController {
 
 	void createGroup(String name,
diff --git a/briar-android/src/org/briarproject/android/privategroup/creation/CreateGroupControllerImpl.java b/briar-android/src/org/briarproject/android/privategroup/creation/CreateGroupControllerImpl.java
index b5632e9bf35d0aaa14cf2e40ae3bdea038ea2d61..b37fc5e499829e0ef914a95937c225b28eabc3f2 100644
--- a/briar-android/src/org/briarproject/android/privategroup/creation/CreateGroupControllerImpl.java
+++ b/briar-android/src/org/briarproject/android/privategroup/creation/CreateGroupControllerImpl.java
@@ -2,57 +2,75 @@ package org.briarproject.android.privategroup.creation;
 
 import org.briarproject.android.controller.DbControllerImpl;
 import org.briarproject.android.controller.handler.ResultExceptionHandler;
+import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.contact.ContactManager;
 import org.briarproject.api.crypto.CryptoExecutor;
 import org.briarproject.api.db.DatabaseExecutor;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.NoSuchContactException;
 import org.briarproject.api.identity.IdentityManager;
 import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.lifecycle.LifecycleManager;
+import org.briarproject.api.nullsafety.NotNullByDefault;
 import org.briarproject.api.privategroup.GroupMessage;
 import org.briarproject.api.privategroup.GroupMessageFactory;
 import org.briarproject.api.privategroup.PrivateGroup;
 import org.briarproject.api.privategroup.PrivateGroupFactory;
 import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationFactory;
+import org.briarproject.api.privategroup.invitation.GroupInvitationManager;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.system.Clock;
 
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.List;
 import java.util.concurrent.Executor;
 import java.util.logging.Logger;
 
+import javax.annotation.concurrent.Immutable;
 import javax.inject.Inject;
 
 import static java.util.logging.Level.WARNING;
 
+@Immutable
+@NotNullByDefault
 public class CreateGroupControllerImpl extends DbControllerImpl
 		implements CreateGroupController {
 
 	private static final Logger LOG =
 			Logger.getLogger(CreateGroupControllerImpl.class.getName());
 
+	private final Executor cryptoExecutor;
+	private final ContactManager contactManager;
 	private final IdentityManager identityManager;
 	private final PrivateGroupFactory groupFactory;
 	private final GroupMessageFactory groupMessageFactory;
 	private final PrivateGroupManager groupManager;
+	private final GroupInvitationFactory groupInvitationFactory;
+	private final GroupInvitationManager groupInvitationManager;
 	private final Clock clock;
-	@CryptoExecutor
-	private final Executor cryptoExecutor;
 
 	@Inject
 	CreateGroupControllerImpl(@DatabaseExecutor Executor dbExecutor,
 			@CryptoExecutor Executor cryptoExecutor,
-			LifecycleManager lifecycleManager, IdentityManager identityManager,
-			PrivateGroupFactory groupFactory,
+			LifecycleManager lifecycleManager, ContactManager contactManager,
+			IdentityManager identityManager, PrivateGroupFactory groupFactory,
 			GroupMessageFactory groupMessageFactory,
-			PrivateGroupManager groupManager, Clock clock) {
+			PrivateGroupManager groupManager,
+			GroupInvitationFactory groupInvitationFactory,
+			GroupInvitationManager groupInvitationManager, Clock clock) {
 		super(dbExecutor, lifecycleManager);
+		this.cryptoExecutor = cryptoExecutor;
+		this.contactManager = contactManager;
 		this.identityManager = identityManager;
 		this.groupFactory = groupFactory;
 		this.groupMessageFactory = groupMessageFactory;
 		this.groupManager = groupManager;
+		this.groupInvitationFactory = groupInvitationFactory;
+		this.groupInvitationManager = groupInvitationManager;
 		this.clock = clock;
-		this.cryptoExecutor = cryptoExecutor;
 	}
 
 	@Override
@@ -111,17 +129,89 @@ public class CreateGroupControllerImpl extends DbControllerImpl
 	}
 
 	@Override
-	public void sendInvitation(final GroupId groupId,
-			final Collection<ContactId> contacts, final String message,
-			final ResultExceptionHandler<Void, DbException> result) {
+	public void sendInvitation(final GroupId g,
+			final Collection<ContactId> contactIds, final String message,
+			final ResultExceptionHandler<Void, DbException> handler) {
+		runOnDbThread(new Runnable() {
+			@Override
+			public void run() {
+				try {
+					LocalAuthor localAuthor = identityManager.getLocalAuthor();
+					List<Contact> contacts = new ArrayList<>();
+					for (ContactId c : contactIds) {
+						try {
+							contacts.add(contactManager.getContact(c));
+						} catch (NoSuchContactException e) {
+							// Continue
+						}
+					}
+					signInvitations(g, localAuthor, contacts, message, handler);
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+					handler.onException(e);
+				}
+			}
+		});
+	}
+
+	private void signInvitations(final GroupId g, final LocalAuthor localAuthor,
+			final Collection<Contact> contacts, final String message,
+			final ResultExceptionHandler<Void, DbException> handler) {
+		cryptoExecutor.execute(new Runnable() {
+			@Override
+			public void run() {
+				long timestamp = clock.currentTimeMillis();
+				List<InvitationContext> contexts = new ArrayList<>();
+				for (Contact c : contacts) {
+					byte[] signature = groupInvitationFactory.signInvitation(c,
+							g, timestamp, localAuthor.getPrivateKey());
+					contexts.add(new InvitationContext(c.getId(), timestamp,
+							signature));
+				}
+				sendInvitations(g, contexts, message, handler);
+			}
+		});
+	}
+
+	private void sendInvitations(final GroupId g,
+			final Collection<InvitationContext> contexts, final String message,
+			final ResultExceptionHandler<Void, DbException> handler) {
 		runOnDbThread(new Runnable() {
 			@Override
 			public void run() {
-				// TODO actually send invitation
-				//noinspection ConstantConditions
-				result.onResult(null);
+				try {
+					String msg = message.isEmpty() ? null : message;
+					for (InvitationContext context : contexts) {
+						try {
+							groupInvitationManager.sendInvitation(g,
+									context.contactId, msg, context.timestamp,
+									context.signature);
+						} catch (NoSuchContactException e) {
+							// Continue
+						}
+					}
+					handler.onResult(null);
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+					handler.onException(e);
+				}
 			}
 		});
 	}
 
+	private static class InvitationContext {
+
+		private final ContactId contactId;
+		private final long timestamp;
+		private final byte[] signature;
+
+		private InvitationContext(ContactId contactId, long timestamp,
+				byte[] signature) {
+			this.contactId = contactId;
+			this.timestamp = timestamp;
+			this.signature = signature;
+		}
+	}
 }
diff --git a/briar-android/src/org/briarproject/android/privategroup/creation/GroupInviteActivity.java b/briar-android/src/org/briarproject/android/privategroup/creation/GroupInviteActivity.java
index 7872c34cb72acc5abe01615d2f5d234ae7a85da6..abc776f5136ccecc338f95bed91610c9aec0de5b 100644
--- a/briar-android/src/org/briarproject/android/privategroup/creation/GroupInviteActivity.java
+++ b/briar-android/src/org/briarproject/android/privategroup/creation/GroupInviteActivity.java
@@ -10,11 +10,17 @@ import org.briarproject.android.sharing.ContactSelectorFragment;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.db.DatabaseExecutor;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.privategroup.invitation.GroupInvitationManager;
 import org.briarproject.api.sync.GroupId;
 
+import javax.inject.Inject;
+
 public class GroupInviteActivity extends BaseGroupInviteActivity
 		implements MessageFragmentListener {
 
+	@Inject
+	GroupInvitationManager groupInvitationManager;
+
 	@Override
 	public void injectActivity(ActivityComponent component) {
 		component.inject(this);
@@ -42,9 +48,8 @@ public class GroupInviteActivity extends BaseGroupInviteActivity
 
 	@Override
 	@DatabaseExecutor
-	public boolean isDisabled(GroupId groupId, Contact c) throws DbException {
-		// TODO disable contacts that can not be invited
-		return false;
+	public boolean isDisabled(GroupId g, Contact c) throws DbException {
+		return !groupInvitationManager.isInvitationAllowed(c, g);
 	}
 
 }
diff --git a/briar-android/src/org/briarproject/android/privategroup/invitation/GroupInvitationControllerImpl.java b/briar-android/src/org/briarproject/android/privategroup/invitation/GroupInvitationControllerImpl.java
index 9dba77d895f3e4bc009d4c4e1b90c6748c0ebeb6..d77f04c25d8293f1233a296df8ae8ca1267eb7ae 100644
--- a/briar-android/src/org/briarproject/android/privategroup/invitation/GroupInvitationControllerImpl.java
+++ b/briar-android/src/org/briarproject/android/privategroup/invitation/GroupInvitationControllerImpl.java
@@ -2,12 +2,13 @@ package org.briarproject.android.privategroup.invitation;
 
 import org.briarproject.android.controller.handler.ResultExceptionHandler;
 import org.briarproject.android.sharing.InvitationControllerImpl;
-import org.briarproject.api.contact.Contact;
+import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.db.DatabaseExecutor;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.event.Event;
 import org.briarproject.api.event.EventBus;
-import org.briarproject.api.event.GroupInvitationReceivedEvent;
+import org.briarproject.api.event.GroupInvitationRequestReceivedEvent;
+import org.briarproject.api.event.GroupInvitationResponseReceivedEvent;
 import org.briarproject.api.lifecycle.LifecycleManager;
 import org.briarproject.api.privategroup.PrivateGroup;
 import org.briarproject.api.privategroup.PrivateGroupManager;
@@ -44,8 +45,11 @@ public class GroupInvitationControllerImpl
 	public void eventOccurred(Event e) {
 		super.eventOccurred(e);
 
-		if (e instanceof GroupInvitationReceivedEvent) {
-			LOG.info("Group invitation received, reloading");
+		if (e instanceof GroupInvitationRequestReceivedEvent) {
+			LOG.info("Group invitation request received, reloading");
+			listener.loadInvitations(false);
+		} else if (e instanceof GroupInvitationResponseReceivedEvent) {
+			LOG.info("Group invitation response received, reloading");
 			listener.loadInvitations(false);
 		}
 	}
@@ -70,8 +74,8 @@ public class GroupInvitationControllerImpl
 			public void run() {
 				try {
 					PrivateGroup g = item.getShareable();
-					Contact c = item.getCreator();
-					groupInvitationManager.respondToInvitation(g, c, accept);
+					ContactId c = item.getCreator().getId();
+					groupInvitationManager.respondToInvitation(c, g, accept);
 				} catch (DbException e) {
 					if (LOG.isLoggable(WARNING))
 						LOG.log(WARNING, e.toString(), e);
diff --git a/briar-android/src/org/briarproject/android/privategroup/list/GroupListControllerImpl.java b/briar-android/src/org/briarproject/android/privategroup/list/GroupListControllerImpl.java
index f464b598027ddd872b0730e41f85dde8fe0f18db..d2dc087f3b9a33a94740cca9322dd77926dfb4de 100644
--- a/briar-android/src/org/briarproject/android/privategroup/list/GroupListControllerImpl.java
+++ b/briar-android/src/org/briarproject/android/privategroup/list/GroupListControllerImpl.java
@@ -14,7 +14,7 @@ import org.briarproject.api.event.EventBus;
 import org.briarproject.api.event.EventListener;
 import org.briarproject.api.event.GroupAddedEvent;
 import org.briarproject.api.event.GroupDissolvedEvent;
-import org.briarproject.api.event.GroupInvitationReceivedEvent;
+import org.briarproject.api.event.GroupInvitationRequestReceivedEvent;
 import org.briarproject.api.event.GroupMessageAddedEvent;
 import org.briarproject.api.event.GroupRemovedEvent;
 import org.briarproject.api.lifecycle.LifecycleManager;
@@ -92,8 +92,7 @@ public class GroupListControllerImpl extends DbControllerImpl
 			GroupMessageAddedEvent g = (GroupMessageAddedEvent) e;
 			LOG.info("Private group message added");
 			onGroupMessageAdded(g.getHeader());
-		} else if (e instanceof GroupInvitationReceivedEvent) {
-			GroupInvitationReceivedEvent g = (GroupInvitationReceivedEvent) e;
+		} else if (e instanceof GroupInvitationRequestReceivedEvent) {
 			LOG.info("Private group invitation received");
 			onGroupInvitationReceived();
 		} else if (e instanceof GroupAddedEvent) {
diff --git a/briar-api/src/org/briarproject/api/event/GroupInvitationReceivedEvent.java b/briar-api/src/org/briarproject/api/event/GroupInvitationRequestReceivedEvent.java
similarity index 58%
rename from briar-api/src/org/briarproject/api/event/GroupInvitationReceivedEvent.java
rename to briar-api/src/org/briarproject/api/event/GroupInvitationRequestReceivedEvent.java
index c8244a39248f621d4a829002afb232eed4f7208c..1fc2599c22f77ee4183c7da1836caa31b5a57af8 100644
--- a/briar-api/src/org/briarproject/api/event/GroupInvitationReceivedEvent.java
+++ b/briar-api/src/org/briarproject/api/event/GroupInvitationRequestReceivedEvent.java
@@ -1,15 +1,14 @@
 package org.briarproject.api.event;
 
 import org.briarproject.api.contact.ContactId;
-import org.briarproject.api.forum.ForumInvitationRequest;
 import org.briarproject.api.privategroup.PrivateGroup;
 import org.briarproject.api.privategroup.invitation.GroupInvitationRequest;
 
-public class GroupInvitationReceivedEvent extends
+public class GroupInvitationRequestReceivedEvent extends
 		InvitationRequestReceivedEvent<PrivateGroup> {
 
-	public GroupInvitationReceivedEvent(PrivateGroup group, ContactId contactId,
-			GroupInvitationRequest request) {
+	public GroupInvitationRequestReceivedEvent(PrivateGroup group,
+			ContactId contactId, GroupInvitationRequest request) {
 		super(group, contactId, request);
 	}
 
diff --git a/briar-api/src/org/briarproject/api/event/GroupInvitationResponseReceivedEvent.java b/briar-api/src/org/briarproject/api/event/GroupInvitationResponseReceivedEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..a72fd313af7b3899b85fc7539b756270456f99cd
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/event/GroupInvitationResponseReceivedEvent.java
@@ -0,0 +1,13 @@
+package org.briarproject.api.event;
+
+import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.sharing.InvitationResponse;
+
+public class GroupInvitationResponseReceivedEvent
+		extends InvitationResponseReceivedEvent {
+
+	public GroupInvitationResponseReceivedEvent(ContactId contactId,
+			InvitationResponse response) {
+		super(contactId, response);
+	}
+}
diff --git a/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java b/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java
index 85b190452ffd875010d07b2f1a416f12e11ed46f..4a96a843b999537f809ca6fa99a33c69aab5cdab 100644
--- a/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java
+++ b/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java
@@ -14,38 +14,63 @@ import java.util.Collection;
 @NotNullByDefault
 public interface PrivateGroupManager extends MessageTracker {
 
-	/** The unique ID of the private group client. */
+	/**
+	 * The unique ID of the private group client.
+	 */
 	ClientId CLIENT_ID = new ClientId("org.briarproject.briar.privategroup");
 
 	/**
 	 * Adds a new private group and joins it.
 	 *
 	 * @param group        The private group to add
-	 * @param joinMsg      The creator's own join message
+	 * @param joinMsg      The new member's join message
 	 */
 	void addPrivateGroup(PrivateGroup group, GroupMessage joinMsg)
 			throws DbException;
 
-	/** Removes a dissolved private group. */
+	/**
+	 * Adds a new private group and joins it.
+	 *
+	 * @param group        The private group to add
+	 * @param joinMsg      The new member's join message
+	 */
+	void addPrivateGroup(Transaction txn, PrivateGroup group,
+			GroupMessage joinMsg) throws DbException;
+
+	/**
+	 * Removes a dissolved private group.
+	 */
 	void removePrivateGroup(GroupId g) throws DbException;
 
-	/** Gets the MessageId of your previous message sent to the group */
+	/**
+	 * Gets the MessageId of your previous message sent to the group
+	 */
 	MessageId getPreviousMsgId(GroupId g) throws DbException;
 
-	/** Returns the timestamp of the message with the given ID */
+	/**
+	 * Returns the timestamp of the message with the given ID
+	 */
 	// TODO change to getPreviousMessageHeader()
 	long getMessageTimestamp(MessageId id) throws DbException;
 
-	/** Marks the group with GroupId g as resolved */
+	/**
+	 * Marks the group with GroupId g as resolved
+	 */
 	void markGroupDissolved(Transaction txn, GroupId g) throws DbException;
 
-	/** Returns true if the private group has been dissolved. */
+	/**
+	 * Returns true if the private group has been dissolved.
+	 */
 	boolean isDissolved(GroupId g) throws DbException;
 
-	/** Stores (and sends) a local group message. */
+	/**
+	 * Stores (and sends) a local group message.
+	 */
 	GroupMessageHeader addLocalMessage(GroupMessage p) throws DbException;
 
-	/** Returns the private group with the given ID. */
+	/**
+	 * Returns the private group with the given ID.
+	 */
 	PrivateGroup getPrivateGroup(GroupId g) throws DbException;
 
 	/**
@@ -53,25 +78,35 @@ public interface PrivateGroupManager extends MessageTracker {
 	 */
 	PrivateGroup getPrivateGroup(Transaction txn, GroupId g) throws DbException;
 
-	/** Returns all private groups the user is a member of. */
+	/**
+	 * Returns all private groups the user is a member of.
+	 */
 	Collection<PrivateGroup> getPrivateGroups() throws DbException;
 
-	/** Returns the body of the group message with the given ID. */
+	/**
+	 * Returns the body of the group message with the given ID.
+	 */
 	String getMessageBody(MessageId m) throws DbException;
 
-	/** Returns the headers of all group messages in the given group. */
+	/**
+	 * Returns the headers of all group messages in the given group.
+	 */
 	Collection<GroupMessageHeader> getHeaders(GroupId g) throws DbException;
 
-	/** Returns all members of the group with ID g */
+	/**
+	 * Returns all members of the group with ID g
+	 */
 	Collection<GroupMember> getMembers(GroupId g) throws DbException;
 
-	/** Returns true if the given Author a is member of the group with ID g */
+	/**
+	 * Returns true if the given Author a is member of the group with ID g
+	 */
 	boolean isMember(Transaction txn, GroupId g, Author a) throws DbException;
 
 	/**
 	 * Registers a hook to be called when members are added
 	 * or groups are removed.
-	 * */
+	 */
 	void registerPrivateGroupHook(PrivateGroupHook hook);
 
 	@NotNullByDefault
diff --git a/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationConstants.java b/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationConstants.java
deleted file mode 100644
index a22272b22b2f3bb4549e7abebc4e65dc2677694a..0000000000000000000000000000000000000000
--- a/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationConstants.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package org.briarproject.api.privategroup.invitation;
-
-public interface GroupInvitationConstants {
-
-	// Group Metadata Keys
-	String CONTACT_ID = "contactId";
-
-}
diff --git a/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationFactory.java b/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..e5cf41adf714a6e73ee0a36bdac94e3305a2b596
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationFactory.java
@@ -0,0 +1,27 @@
+package org.briarproject.api.privategroup.invitation;
+
+import org.briarproject.api.contact.Contact;
+import org.briarproject.api.crypto.CryptoExecutor;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.sync.GroupId;
+
+public interface GroupInvitationFactory {
+
+	/**
+	 * Returns a signature to include when inviting a member to join a private
+	 * group. If the member accepts the invitation, the signature will be
+	 * included in the member's join message.
+	 */
+	@CryptoExecutor
+	byte[] signInvitation(Contact c, GroupId privateGroupId, long timestamp,
+			byte[] privateKey);
+
+	/**
+	 * Returns a token to be signed by the creator when inviting a member to
+	 * join a private group. If the member accepts the invitation, the
+	 * signature will be included in the member's join message.
+	 */
+	BdfList createInviteToken(AuthorId creatorId, AuthorId memberId,
+			GroupId privateGroupId, long timestamp);
+}
diff --git a/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationItem.java b/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationItem.java
index 5cb23afee77575127930ea215d866d8179995fd9..7fb91d6acd6689d80c9bdecc717d97ff423e1f51 100644
--- a/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationItem.java
+++ b/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationItem.java
@@ -13,10 +13,8 @@ public class GroupInvitationItem extends InvitationItem<PrivateGroup> {
 
 	private final Contact creator;
 
-	public GroupInvitationItem(PrivateGroup shareable, boolean subscribed,
-			Contact creator) {
-		super(shareable, subscribed);
-
+	public GroupInvitationItem(PrivateGroup privateGroup, Contact creator) {
+		super(privateGroup, false);
 		this.creator = creator;
 	}
 
diff --git a/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationManager.java b/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationManager.java
index 745b69fc8cc5a34872391628e7044c051064ad54..489e51a5917bc5f477cef44bd6ff2d71d82d9218 100644
--- a/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationManager.java
+++ b/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationManager.java
@@ -1,10 +1,10 @@
 package org.briarproject.api.privategroup.invitation;
 
-import org.briarproject.api.clients.MessageTracker;
 import org.briarproject.api.clients.SessionId;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.nullsafety.NotNullByDefault;
 import org.briarproject.api.privategroup.PrivateGroup;
 import org.briarproject.api.sharing.InvitationMessage;
 import org.briarproject.api.sync.ClientId;
@@ -12,38 +12,51 @@ import org.briarproject.api.sync.GroupId;
 
 import java.util.Collection;
 
-public interface GroupInvitationManager extends MessageTracker {
+import javax.annotation.Nullable;
 
-	/** The unique ID of the private group invitation client. */
+@NotNullByDefault
+public interface GroupInvitationManager {
+
+	/**
+	 * The unique ID of the private group invitation client.
+	 */
 	ClientId CLIENT_ID =
 			new ClientId("org.briarproject.briar.privategroup.invitation");
 
 	/**
-	 * Sends an invitation to share the given forum with the given contact
-	 * and sends an optional message along with it.
+	 * Sends an invitation to share the given private group with the given
+	 * contact, including an optional message.
 	 */
-	void sendInvitation(GroupId groupId, ContactId contactId,
-			String message)	throws DbException;
+	void sendInvitation(GroupId g, ContactId c, @Nullable String message,
+			long timestamp, byte[] signature) throws DbException;
 
 	/**
-	 * Responds to a pending private group invitation
+	 * Responds to a pending private group invitation from the given contact.
 	 */
-	void respondToInvitation(PrivateGroup g, Contact c, boolean accept)
+	void respondToInvitation(ContactId c, PrivateGroup g, boolean accept)
 			throws DbException;
 
 	/**
-	 * Responds to a pending private group invitation
+	 * Responds to a pending private group invitation from the given contact.
 	 */
-	void respondToInvitation(SessionId id, boolean accept) throws DbException;
+	void respondToInvitation(ContactId c, SessionId s, boolean accept)
+			throws DbException;
 
 	/**
-	 * Returns all private group invitation messages related to the contact
-	 * identified by contactId.
+	 * Returns all private group invitation messages related to the given
+	 * contact.
 	 */
-	Collection<InvitationMessage> getInvitationMessages(
-			ContactId contactId) throws DbException;
+	Collection<InvitationMessage> getInvitationMessages(ContactId c)
+			throws DbException;
 
-	/** Returns all private groups to which the user has been invited. */
+	/**
+	 * Returns all private groups to which the user has been invited.
+	 */
 	Collection<GroupInvitationItem> getInvitations() throws DbException;
 
+	/**
+	 * Returns true if the given contact can be invited to the given private
+	 * group.
+	 */
+	boolean isInvitationAllowed(Contact c, GroupId g) throws DbException;
 }
diff --git a/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationRequest.java b/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationRequest.java
index 3115a6bc27d49a8c32e1ef28c03afc4b1cca4028..d98bcad2e187deefa1214ebebe2ead758ea53890 100644
--- a/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationRequest.java
+++ b/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationRequest.java
@@ -8,8 +8,8 @@ import org.briarproject.api.sharing.InvitationRequest;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.MessageId;
 
+import javax.annotation.Nullable;
 import javax.annotation.concurrent.Immutable;
-import javax.annotation.concurrent.ThreadSafe;
 
 @Immutable
 @NotNullByDefault
@@ -19,8 +19,8 @@ public class GroupInvitationRequest extends InvitationRequest {
 	private final Author creator;
 
 	public GroupInvitationRequest(MessageId id, SessionId sessionId,
-			GroupId groupId, Author creator, ContactId contactId,
-			String groupName, String message, boolean available, long time,
+			GroupId groupId, ContactId contactId, @Nullable String message,
+			String groupName, Author creator, boolean available, long time,
 			boolean local, boolean sent, boolean seen, boolean read) {
 		super(id, sessionId, groupId, contactId, message, available, time,
 				local, sent, seen, read);
diff --git a/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationResponse.java b/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationResponse.java
index dda5d554ac6ce1b8b78cc8a68f271038a695b457..ba41a8b25ef0c67f581630c94ff6dcb2a18aa0d4 100644
--- a/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationResponse.java
+++ b/briar-api/src/org/briarproject/api/privategroup/invitation/GroupInvitationResponse.java
@@ -2,39 +2,21 @@ package org.briarproject.api.privategroup.invitation;
 
 import org.briarproject.api.clients.SessionId;
 import org.briarproject.api.contact.ContactId;
-import org.briarproject.api.identity.Author;
 import org.briarproject.api.nullsafety.NotNullByDefault;
 import org.briarproject.api.sharing.InvitationResponse;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.MessageId;
-import org.jetbrains.annotations.NotNull;
 
 import javax.annotation.concurrent.Immutable;
-import javax.annotation.concurrent.ThreadSafe;
 
 @Immutable
 @NotNullByDefault
 public class GroupInvitationResponse extends InvitationResponse {
 
-	private final String groupName;
-	private final Author creator;
-
 	public GroupInvitationResponse(MessageId id, SessionId sessionId,
-			GroupId groupId, String groupName, Author creator,
-			ContactId contactId, boolean accept, long time, boolean local,
-			boolean sent, boolean seen, boolean read) {
+			GroupId groupId, ContactId contactId, boolean accept, long time,
+			boolean local, boolean sent, boolean seen, boolean read) {
 		super(id, sessionId, groupId, contactId, accept, time, local, sent,
 				seen, read);
-		this.groupName = groupName;
-		this.creator = creator;
-	}
-
-	public String getGroupName() {
-		return groupName;
 	}
-
-	public Author getCreator() {
-		return creator;
-	}
-
 }
diff --git a/briar-core/src/org/briarproject/CoreEagerSingletons.java b/briar-core/src/org/briarproject/CoreEagerSingletons.java
index b4b7402dd9b7fb265ab9aa30c887f8f4089f3da4..314543a31374e4701c6cd3cfd40e0666330294c9 100644
--- a/briar-core/src/org/briarproject/CoreEagerSingletons.java
+++ b/briar-core/src/org/briarproject/CoreEagerSingletons.java
@@ -1,7 +1,5 @@
 package org.briarproject;
 
-import org.briarproject.api.privategroup.PrivateGroup;
-import org.briarproject.api.privategroup.PrivateGroupManager;
 import org.briarproject.blogs.BlogsModule;
 import org.briarproject.contact.ContactModule;
 import org.briarproject.crypto.CryptoModule;
@@ -14,6 +12,7 @@ import org.briarproject.lifecycle.LifecycleModule;
 import org.briarproject.messaging.MessagingModule;
 import org.briarproject.plugins.PluginsModule;
 import org.briarproject.privategroup.PrivateGroupModule;
+import org.briarproject.privategroup.invitation.GroupInvitationModule;
 import org.briarproject.properties.PropertiesModule;
 import org.briarproject.sharing.SharingModule;
 import org.briarproject.sync.SyncModule;
@@ -32,6 +31,8 @@ public interface CoreEagerSingletons {
 
 	void inject(ForumModule.EagerSingletons init);
 
+	void inject(GroupInvitationModule.EagerSingletons init);
+
 	void inject(IdentityModule.EagerSingletons init);
 
 	void inject(IntroductionModule.EagerSingletons init);
diff --git a/briar-core/src/org/briarproject/CoreModule.java b/briar-core/src/org/briarproject/CoreModule.java
index 82754d66880e509cd1d3dbdafd78a4a6579b8a4f..aee24de22f3d4264c2c88f63bd528a6aa01ce97d 100644
--- a/briar-core/src/org/briarproject/CoreModule.java
+++ b/briar-core/src/org/briarproject/CoreModule.java
@@ -18,6 +18,7 @@ import org.briarproject.lifecycle.LifecycleModule;
 import org.briarproject.messaging.MessagingModule;
 import org.briarproject.plugins.PluginsModule;
 import org.briarproject.privategroup.PrivateGroupModule;
+import org.briarproject.privategroup.invitation.GroupInvitationModule;
 import org.briarproject.properties.PropertiesModule;
 import org.briarproject.reliability.ReliabilityModule;
 import org.briarproject.reporting.ReportingModule;
@@ -40,6 +41,7 @@ import dagger.Module;
 		DatabaseExecutorModule.class,
 		EventModule.class,
 		ForumModule.class,
+		GroupInvitationModule.class,
 		IdentityModule.class,
 		IntroductionModule.class,
 		InvitationModule.class,
@@ -67,6 +69,7 @@ public class CoreModule {
 		c.inject(new CryptoModule.EagerSingletons());
 		c.inject(new DatabaseExecutorModule.EagerSingletons());
 		c.inject(new ForumModule.EagerSingletons());
+		c.inject(new GroupInvitationModule.EagerSingletons());
 		c.inject(new IdentityModule.EagerSingletons());
 		c.inject(new LifecycleModule.EagerSingletons());
 		c.inject(new MessagingModule.EagerSingletons());
diff --git a/briar-core/src/org/briarproject/privategroup/Constants.java b/briar-core/src/org/briarproject/privategroup/GroupConstants.java
similarity index 100%
rename from briar-core/src/org/briarproject/privategroup/Constants.java
rename to briar-core/src/org/briarproject/privategroup/GroupConstants.java
diff --git a/briar-core/src/org/briarproject/privategroup/GroupMessageValidator.java b/briar-core/src/org/briarproject/privategroup/GroupMessageValidator.java
index e1613b04562ee4a6d94387ffc32438abe0f98369..dda084d2e1e4fa7c13495468ab036dcf15692ee2 100644
--- a/briar-core/src/org/briarproject/privategroup/GroupMessageValidator.java
+++ b/briar-core/src/org/briarproject/privategroup/GroupMessageValidator.java
@@ -3,7 +3,6 @@ package org.briarproject.privategroup;
 import org.briarproject.api.FormatException;
 import org.briarproject.api.clients.BdfMessageContext;
 import org.briarproject.api.clients.ClientHelper;
-import org.briarproject.api.clients.ContactGroupFactory;
 import org.briarproject.api.data.BdfDictionary;
 import org.briarproject.api.data.BdfList;
 import org.briarproject.api.data.MetadataEncoder;
@@ -12,6 +11,7 @@ import org.briarproject.api.identity.AuthorFactory;
 import org.briarproject.api.privategroup.MessageType;
 import org.briarproject.api.privategroup.PrivateGroup;
 import org.briarproject.api.privategroup.PrivateGroupFactory;
+import org.briarproject.api.privategroup.invitation.GroupInvitationFactory;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.InvalidMessageException;
 import org.briarproject.api.sync.Message;
@@ -29,7 +29,6 @@ import static org.briarproject.api.identity.AuthorConstants.MAX_SIGNATURE_LENGTH
 import static org.briarproject.api.privategroup.MessageType.JOIN;
 import static org.briarproject.api.privategroup.MessageType.POST;
 import static org.briarproject.api.privategroup.PrivateGroupConstants.MAX_GROUP_POST_BODY_LENGTH;
-import static org.briarproject.api.privategroup.invitation.GroupInvitationManager.CLIENT_ID;
 import static org.briarproject.privategroup.Constants.KEY_MEMBER_ID;
 import static org.briarproject.privategroup.Constants.KEY_MEMBER_NAME;
 import static org.briarproject.privategroup.Constants.KEY_MEMBER_PUBLIC_KEY;
@@ -41,18 +40,18 @@ import static org.briarproject.privategroup.Constants.KEY_TYPE;
 
 class GroupMessageValidator extends BdfMessageValidator {
 
-	private final ContactGroupFactory contactGroupFactory;
-	private final PrivateGroupFactory groupFactory;
+	private final PrivateGroupFactory privateGroupFactory;
 	private final AuthorFactory authorFactory;
+	private final GroupInvitationFactory groupInvitationFactory;
 
-	GroupMessageValidator(ContactGroupFactory contactGroupFactory,
-			PrivateGroupFactory groupFactory,
+	GroupMessageValidator(PrivateGroupFactory privateGroupFactory,
 			ClientHelper clientHelper, MetadataEncoder metadataEncoder,
-			Clock clock, AuthorFactory authorFactory) {
+			Clock clock, AuthorFactory authorFactory,
+			GroupInvitationFactory groupInvitationFactory) {
 		super(clientHelper, metadataEncoder, clock);
-		this.contactGroupFactory = contactGroupFactory;
-		this.groupFactory = groupFactory;
+		this.privateGroupFactory = privateGroupFactory;
 		this.authorFactory = authorFactory;
+		this.groupInvitationFactory = groupInvitationFactory;
 	}
 
 	@Override
@@ -96,15 +95,16 @@ class GroupMessageValidator extends BdfMessageValidator {
 
 		// The content is a BDF list with five elements
 		checkSize(body, 5);
-		PrivateGroup pg = groupFactory.parsePrivateGroup(g);
+		PrivateGroup pg = privateGroupFactory.parsePrivateGroup(g);
 
 		// invite is null if the member is the creator of the private group
+		Author creator = pg.getAuthor();
 		BdfList invite = body.getOptionalList(3);
 		if (invite == null) {
-			if (!member.equals(pg.getAuthor()))
+			if (!member.equals(creator))
 				throw new InvalidMessageException();
 		} else {
-			if (member.equals(pg.getAuthor()))
+			if (member.equals(creator))
 				throw new InvalidMessageException();
 
 			// Otherwise invite is a list with two elements
@@ -120,21 +120,13 @@ class GroupMessageValidator extends BdfMessageValidator {
 			byte[] creatorSignature = invite.getRaw(1);
 			checkLength(creatorSignature, 1, MAX_SIGNATURE_LENGTH);
 
-			// derive invitation group
-			Group invitationGroup = contactGroupFactory
-					.createContactGroup(CLIENT_ID, pg.getAuthor().getId(),
-							member.getId());
-
-			// signature with the creator's private key
-			// over a list with four elements:
-			// invite_type (int), invite_timestamp (int),
-			// invitation_group_id (raw), and private_group_id (raw)
-			BdfList signed =
-					BdfList.of(0, inviteTimestamp, invitationGroup.getId(),
-							g.getId());
+			// the invite token is signed by the creator of the private group
+			BdfList token = groupInvitationFactory
+					.createInviteToken(creator.getId(), member.getId(),
+							pg.getId(), inviteTimestamp);
 			try {
 				clientHelper.verifySignature(creatorSignature,
-						pg.getAuthor().getPublicKey(), signed);
+						creator.getPublicKey(), token);
 			} catch (GeneralSecurityException e) {
 				throw new InvalidMessageException(e);
 			}
diff --git a/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java b/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java
index d7f705eb2ed2bee7edee7b241b6587511065cd94..b6117f65f198a4029d1eb8021069c031cacf3366 100644
--- a/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java
+++ b/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java
@@ -2,6 +2,7 @@ package org.briarproject.privategroup;
 
 import org.briarproject.api.FormatException;
 import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.data.BdfDictionary;
 import org.briarproject.api.data.BdfEntry;
@@ -86,6 +87,17 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 	public void addPrivateGroup(PrivateGroup group, GroupMessage joinMsg)
 			throws DbException {
 		Transaction txn = db.startTransaction(false);
+		try {
+			addPrivateGroup(txn, group, joinMsg);
+			db.commitTransaction(txn);
+		} finally {
+			db.endTransaction(txn);
+		}
+	}
+
+	@Override
+	public void addPrivateGroup(Transaction txn, PrivateGroup group,
+			GroupMessage joinMsg) throws DbException {
 		try {
 			db.addGroup(txn, group.getGroup());
 			BdfDictionary meta = BdfDictionary.of(
@@ -94,11 +106,8 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 			);
 			clientHelper.mergeGroupMetadata(txn, group.getId(), meta);
 			joinPrivateGroup(txn, joinMsg);
-			db.commitTransaction(txn);
 		} catch (FormatException e) {
 			throw new DbException(e);
-		} finally {
-			db.endTransaction(txn);
 		}
 	}
 
@@ -285,7 +294,9 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 		try {
 			// type(0), member_name(1), member_public_key(2), parent_id(3),
 			// previous_message_id(4), content(5), signature(6)
-			return clientHelper.getMessageAsList(m).getString(5);
+			BdfList body = clientHelper.getMessageAsList(m);
+			if (body == null) throw new DbException();
+			return body.getString(5);
 		} catch (FormatException e) {
 			throw new DbException(e);
 		}
@@ -370,10 +381,10 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 				Status status = identityManager.getAuthorStatus(txn, a.getId());
 				boolean shared = false;
 				if (status == VERIFIED || status == UNVERIFIED) {
-					Collection<ContactId> contacts =
-							db.getContacts(txn, a.getId());
+					Collection<Contact> contacts =
+							db.getContactsByAuthorId(txn, a.getId());
 					if (contacts.size() != 1) throw new DbException();
-					ContactId c = contacts.iterator().next();
+					ContactId c = contacts.iterator().next().getId();
 					shared = db.isVisibleToContact(txn, c, g);
 				}
 				members.add(new GroupMember(a, status, shared));
@@ -489,6 +500,9 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 				new BdfEntry(KEY_MEMBER_PUBLIC_KEY, a.getPublicKey())
 		));
 		clientHelper.mergeGroupMetadata(txn, g, meta);
+		for (PrivateGroupHook hook : hooks) {
+			hook.addingMember(txn, g, a);
+		}
 	}
 
 	private Author getAuthor(BdfDictionary meta) throws FormatException {
diff --git a/briar-core/src/org/briarproject/privategroup/PrivateGroupModule.java b/briar-core/src/org/briarproject/privategroup/PrivateGroupModule.java
index b4c80cbabd410decd01958419c5a005f1700bcd4..0ec50e101c8fab0ccd3cb4be5542e6f8ee3905aa 100644
--- a/briar-core/src/org/briarproject/privategroup/PrivateGroupModule.java
+++ b/briar-core/src/org/briarproject/privategroup/PrivateGroupModule.java
@@ -1,19 +1,14 @@
 package org.briarproject.privategroup;
 
 import org.briarproject.api.clients.ClientHelper;
-import org.briarproject.api.clients.ContactGroupFactory;
-import org.briarproject.api.contact.ContactManager;
 import org.briarproject.api.data.MetadataEncoder;
 import org.briarproject.api.identity.AuthorFactory;
-import org.briarproject.api.lifecycle.LifecycleManager;
-import org.briarproject.api.messaging.ConversationManager;
 import org.briarproject.api.privategroup.GroupMessageFactory;
 import org.briarproject.api.privategroup.PrivateGroupFactory;
 import org.briarproject.api.privategroup.PrivateGroupManager;
-import org.briarproject.api.privategroup.invitation.GroupInvitationManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationFactory;
 import org.briarproject.api.sync.ValidationManager;
 import org.briarproject.api.system.Clock;
-import org.briarproject.privategroup.invitation.GroupInvitationManagerImpl;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
@@ -21,6 +16,8 @@ import javax.inject.Singleton;
 import dagger.Module;
 import dagger.Provides;
 
+import static org.briarproject.api.privategroup.PrivateGroupManager.CLIENT_ID;
+
 @Module
 public class PrivateGroupModule {
 
@@ -28,19 +25,15 @@ public class PrivateGroupModule {
 		@Inject
 		GroupMessageValidator groupMessageValidator;
 		@Inject
-		GroupInvitationManager groupInvitationManager;
+		PrivateGroupManager groupManager;
 	}
 
 	@Provides
 	@Singleton
-	PrivateGroupManager provideForumManager(
+	PrivateGroupManager provideGroupManager(
 			PrivateGroupManagerImpl groupManager,
 			ValidationManager validationManager) {
-
-		validationManager
-				.registerIncomingMessageHook(PrivateGroupManager.CLIENT_ID,
-						groupManager);
-
+		validationManager.registerIncomingMessageHook(CLIENT_ID, groupManager);
 		return groupManager;
 	}
 
@@ -59,38 +52,16 @@ public class PrivateGroupModule {
 	@Provides
 	@Singleton
 	GroupMessageValidator provideGroupMessageValidator(
-			ContactGroupFactory contactGroupFactory,
-			PrivateGroupFactory groupFactory,
-			ValidationManager validationManager, ClientHelper clientHelper,
-			MetadataEncoder metadataEncoder, Clock clock,
-			AuthorFactory authorFactory,
-			GroupInvitationManager groupInvitationManager) {
-
+			PrivateGroupFactory privateGroupFactory,
+			ClientHelper clientHelper, MetadataEncoder metadataEncoder,
+			Clock clock, AuthorFactory authorFactory,
+			GroupInvitationFactory groupInvitationFactory,
+			ValidationManager validationManager) {
 		GroupMessageValidator validator = new GroupMessageValidator(
-				contactGroupFactory, groupFactory, clientHelper,
-				metadataEncoder, clock, authorFactory);
-		validationManager.registerMessageValidator(
-				PrivateGroupManager.CLIENT_ID, validator);
-
+				privateGroupFactory, clientHelper, metadataEncoder, clock,
+				authorFactory, groupInvitationFactory);
+		validationManager.registerMessageValidator(CLIENT_ID, validator);
 		return validator;
 	}
 
-	@Provides
-	@Singleton
-	GroupInvitationManager provideGroupInvitationManager(
-			LifecycleManager lifecycleManager, ContactManager contactManager,
-			GroupInvitationManagerImpl groupInvitationManager,
-			ConversationManager conversationManager,
-			ValidationManager validationManager) {
-
-		validationManager.registerIncomingMessageHook(
-				GroupInvitationManager.CLIENT_ID, groupInvitationManager);
-		lifecycleManager.registerClient(groupInvitationManager);
-		contactManager.registerAddContactHook(groupInvitationManager);
-		contactManager.registerRemoveContactHook(groupInvitationManager);
-		conversationManager.registerConversationClient(groupInvitationManager);
-
-		return groupInvitationManager;
-	}
-
 }
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/AbortMessage.java b/briar-core/src/org/briarproject/privategroup/invitation/AbortMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..7dd247ee1c18eafcf8f664476b4c2ec10044dc21
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/AbortMessage.java
@@ -0,0 +1,17 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class AbortMessage extends GroupInvitationMessage {
+
+	AbortMessage(MessageId id, GroupId contactGroupId, GroupId privateGroupId,
+			long timestamp) {
+		super(id, contactGroupId, privateGroupId, timestamp);
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/AbstractProtocolEngine.java b/briar-core/src/org/briarproject/privategroup/invitation/AbstractProtocolEngine.java
new file mode 100644
index 0000000000000000000000000000000000000000..b3048c7779a76b9e878f5cbe0037fb3769ace04c
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/AbstractProtocolEngine.java
@@ -0,0 +1,215 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.privategroup.GroupMessage;
+import org.briarproject.api.privategroup.GroupMessageFactory;
+import org.briarproject.api.privategroup.PrivateGroup;
+import org.briarproject.api.privategroup.PrivateGroupFactory;
+import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.system.Clock;
+
+import java.util.Map;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.GROUP_KEY_CONTACT_ID;
+import static org.briarproject.privategroup.invitation.MessageType.ABORT;
+import static org.briarproject.privategroup.invitation.MessageType.INVITE;
+import static org.briarproject.privategroup.invitation.MessageType.JOIN;
+import static org.briarproject.privategroup.invitation.MessageType.LEAVE;
+
+@Immutable
+@NotNullByDefault
+abstract class AbstractProtocolEngine<S extends Session>
+		implements ProtocolEngine<S> {
+
+	protected final DatabaseComponent db;
+	protected final ClientHelper clientHelper;
+	protected final PrivateGroupManager privateGroupManager;
+	protected final PrivateGroupFactory privateGroupFactory;
+
+	private final GroupMessageFactory groupMessageFactory;
+	private final IdentityManager identityManager;
+	private final MessageParser messageParser;
+	private final MessageEncoder messageEncoder;
+	private final Clock clock;
+
+	AbstractProtocolEngine(DatabaseComponent db, ClientHelper clientHelper,
+			PrivateGroupManager privateGroupManager,
+			PrivateGroupFactory privateGroupFactory,
+			GroupMessageFactory groupMessageFactory,
+			IdentityManager identityManager, MessageParser messageParser,
+			MessageEncoder messageEncoder, Clock clock) {
+		this.db = db;
+		this.clientHelper = clientHelper;
+		this.privateGroupManager = privateGroupManager;
+		this.privateGroupFactory = privateGroupFactory;
+		this.groupMessageFactory = groupMessageFactory;
+		this.identityManager = identityManager;
+		this.messageParser = messageParser;
+		this.messageEncoder = messageEncoder;
+		this.clock = clock;
+	}
+
+	ContactId getContactId(Transaction txn, GroupId contactGroupId)
+			throws DbException, FormatException {
+		BdfDictionary meta = clientHelper.getGroupMetadataAsDictionary(txn,
+				contactGroupId);
+		return new ContactId(meta.getLong(GROUP_KEY_CONTACT_ID).intValue());
+	}
+
+	boolean isSubscribedPrivateGroup(Transaction txn, GroupId g)
+			throws DbException {
+		if (!db.containsGroup(txn, g)) return false;
+		Group group = db.getGroup(txn, g);
+		return group.getClientId().equals(PrivateGroupManager.CLIENT_ID);
+	}
+
+	boolean isValidDependency(S session, @Nullable MessageId dependency) {
+		MessageId expected = session.getLastRemoteMessageId();
+		if (dependency == null) return expected == null;
+		return dependency.equals(expected);
+	}
+
+	void syncPrivateGroupWithContact(Transaction txn, S session, boolean sync)
+			throws DbException, FormatException {
+		ContactId contactId = getContactId(txn, session.getContactGroupId());
+		db.setVisibleToContact(txn, contactId, session.getPrivateGroupId(),
+				sync);
+	}
+
+	Message sendInviteMessage(Transaction txn, S session,
+			@Nullable String message, long timestamp, byte[] signature)
+			throws DbException {
+		Group g = db.getGroup(txn, session.getPrivateGroupId());
+		PrivateGroup privateGroup;
+		try {
+			privateGroup = privateGroupFactory.parsePrivateGroup(g);
+		} catch (FormatException e) {
+			throw new DbException(e); // Invalid group descriptor
+		}
+		Message m = messageEncoder.encodeInviteMessage(
+				session.getContactGroupId(), privateGroup.getId(),
+				timestamp, privateGroup.getName(), privateGroup.getAuthor(),
+				privateGroup.getSalt(), message, signature);
+		sendMessage(txn, m, INVITE, privateGroup.getId(), true);
+		return m;
+	}
+
+	Message sendJoinMessage(Transaction txn, S session, boolean visibleInUi)
+			throws DbException {
+		Message m = messageEncoder.encodeJoinMessage(
+				session.getContactGroupId(), session.getPrivateGroupId(),
+				getLocalTimestamp(session), session.getLastLocalMessageId());
+		sendMessage(txn, m, JOIN, session.getPrivateGroupId(), visibleInUi);
+		return m;
+	}
+
+	Message sendLeaveMessage(Transaction txn, S session, boolean visibleInUi)
+			throws DbException {
+		Message m = messageEncoder.encodeLeaveMessage(
+				session.getContactGroupId(), session.getPrivateGroupId(),
+				getLocalTimestamp(session), session.getLastLocalMessageId());
+		sendMessage(txn, m, LEAVE, session.getPrivateGroupId(), visibleInUi);
+		return m;
+	}
+
+	Message sendAbortMessage(Transaction txn, S session) throws DbException {
+		Message m = messageEncoder.encodeAbortMessage(
+				session.getContactGroupId(), session.getPrivateGroupId(),
+				getLocalTimestamp(session));
+		sendMessage(txn, m, ABORT, session.getPrivateGroupId(), false);
+		return m;
+	}
+
+	void markMessageVisibleInUi(Transaction txn, MessageId m, boolean visible)
+			throws DbException {
+		BdfDictionary meta = new BdfDictionary();
+		messageEncoder.setVisibleInUi(meta, visible);
+		try {
+			clientHelper.mergeMessageMetadata(txn, m, meta);
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	void markMessageAvailableToAnswer(Transaction txn, MessageId m,
+			boolean available) throws DbException {
+		BdfDictionary meta = new BdfDictionary();
+		messageEncoder.setAvailableToAnswer(meta, available);
+		try {
+			clientHelper.mergeMessageMetadata(txn, m, meta);
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	void markInvitesUnavailableToAnswer(Transaction txn, S session)
+			throws DbException, FormatException {
+		GroupId privateGroupId = session.getPrivateGroupId();
+		BdfDictionary query =
+				messageParser.getInvitesAvailableToAnswerQuery(privateGroupId);
+		Map<MessageId, BdfDictionary> results =
+				clientHelper.getMessageMetadataAsDictionary(txn,
+						session.getContactGroupId(), query);
+		for (MessageId m : results.keySet())
+			markMessageAvailableToAnswer(txn, m, false);
+	}
+
+	void subscribeToPrivateGroup(Transaction txn, MessageId inviteId)
+			throws DbException, FormatException {
+		InviteMessage invite = getInviteMessage(txn, inviteId);
+		PrivateGroup privateGroup = privateGroupFactory.createPrivateGroup(
+				invite.getGroupName(), invite.getCreator(), invite.getSalt());
+		long timestamp =
+				Math.max(clock.currentTimeMillis(), invite.getTimestamp() + 1);
+		// TODO: Create the join message on the crypto executor
+		LocalAuthor member = identityManager.getLocalAuthor(txn);
+		GroupMessage joinMessage = groupMessageFactory.createJoinMessage(
+				privateGroup.getId(), timestamp, member, invite.getTimestamp(),
+				invite.getSignature());
+		privateGroupManager.addPrivateGroup(txn, privateGroup, joinMessage);
+	}
+
+	long getLocalTimestamp(S session) {
+		return Math.max(clock.currentTimeMillis(),
+				Math.max(session.getLocalTimestamp(),
+						session.getInviteTimestamp()) + 1);
+	}
+
+	private InviteMessage getInviteMessage(Transaction txn, MessageId m)
+			throws DbException, FormatException {
+		Message message = clientHelper.getMessage(txn, m);
+		if (message == null) throw new DbException();
+		BdfList body = clientHelper.toList(message);
+		return messageParser.parseInviteMessage(message, body);
+	}
+
+	private void sendMessage(Transaction txn, Message m, MessageType type,
+			GroupId privateGroupId, boolean visibleInConversation)
+			throws DbException {
+		BdfDictionary meta = messageEncoder.encodeMetadata(type, privateGroupId,
+				m.getTimestamp(), true, true, visibleInConversation, false);
+		try {
+			clientHelper.addLocalMessage(txn, m, meta, true);
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/CreatorProtocolEngine.java b/briar-core/src/org/briarproject/privategroup/invitation/CreatorProtocolEngine.java
new file mode 100644
index 0000000000000000000000000000000000000000..e3e6c0540443c144196cafced5c7fd85ac70ce5c
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/CreatorProtocolEngine.java
@@ -0,0 +1,247 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.clients.ProtocolStateException;
+import org.briarproject.api.clients.SessionId;
+import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.event.GroupInvitationResponseReceivedEvent;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.privategroup.GroupMessageFactory;
+import org.briarproject.api.privategroup.PrivateGroupFactory;
+import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationResponse;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.system.Clock;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import static org.briarproject.privategroup.invitation.CreatorState.DISSOLVED;
+import static org.briarproject.privategroup.invitation.CreatorState.ERROR;
+import static org.briarproject.privategroup.invitation.CreatorState.INVITED;
+import static org.briarproject.privategroup.invitation.CreatorState.INVITEE_JOINED;
+import static org.briarproject.privategroup.invitation.CreatorState.INVITEE_LEFT;
+import static org.briarproject.privategroup.invitation.CreatorState.START;
+
+@Immutable
+@NotNullByDefault
+class CreatorProtocolEngine extends AbstractProtocolEngine<CreatorSession> {
+
+	CreatorProtocolEngine(DatabaseComponent db, ClientHelper clientHelper,
+			PrivateGroupManager privateGroupManager,
+			PrivateGroupFactory privateGroupFactory,
+			GroupMessageFactory groupMessageFactory,
+			IdentityManager identityManager, MessageParser messageParser,
+			MessageEncoder messageEncoder, Clock clock) {
+		super(db, clientHelper, privateGroupManager, privateGroupFactory,
+				groupMessageFactory, identityManager, messageParser,
+				messageEncoder, clock);
+	}
+
+	@Override
+	public CreatorSession onInviteAction(Transaction txn, CreatorSession s,
+			@Nullable String message, long timestamp, byte[] signature)
+			throws DbException {
+		switch (s.getState()) {
+			case START:
+				return onLocalInvite(txn, s, message, timestamp, signature);
+			case INVITED:
+			case INVITEE_JOINED:
+			case INVITEE_LEFT:
+			case DISSOLVED:
+			case ERROR:
+				throw new ProtocolStateException(); // Invalid in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public CreatorSession onJoinAction(Transaction txn, CreatorSession s)
+			throws DbException {
+		throw new UnsupportedOperationException(); // Invalid in this role
+	}
+
+	@Override
+	public CreatorSession onLeaveAction(Transaction txn, CreatorSession s)
+			throws DbException {
+		switch (s.getState()) {
+			case START:
+			case DISSOLVED:
+			case ERROR:
+				return s; // Ignored in these states
+			case INVITED:
+			case INVITEE_JOINED:
+			case INVITEE_LEFT:
+				return onLocalLeave(txn, s);
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public CreatorSession onMemberAddedAction(Transaction txn, CreatorSession s)
+			throws DbException {
+		return s; // Ignored in this role
+	}
+
+	@Override
+	public CreatorSession onInviteMessage(Transaction txn, CreatorSession s,
+			InviteMessage m) throws DbException, FormatException {
+		return abort(txn, s); // Invalid in this role
+	}
+
+	@Override
+	public CreatorSession onJoinMessage(Transaction txn, CreatorSession s,
+			JoinMessage m) throws DbException, FormatException {
+		switch (s.getState()) {
+			case START:
+			case INVITEE_JOINED:
+			case INVITEE_LEFT:
+				return abort(txn, s); // Invalid in these states
+			case INVITED:
+				return onRemoteAccept(txn, s, m);
+			case DISSOLVED:
+			case ERROR:
+				return s; // Ignored in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public CreatorSession onLeaveMessage(Transaction txn, CreatorSession s,
+			LeaveMessage m) throws DbException, FormatException {
+		switch (s.getState()) {
+			case START:
+			case INVITEE_LEFT:
+				return abort(txn, s); // Invalid in these states
+			case INVITED:
+				return onRemoteDecline(txn, s, m);
+			case INVITEE_JOINED:
+				return onRemoteLeave(txn, s, m);
+			case DISSOLVED:
+			case ERROR:
+				return s; // Ignored in these states
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public CreatorSession onAbortMessage(Transaction txn, CreatorSession s,
+			AbortMessage m) throws DbException, FormatException {
+		return abort(txn, s);
+	}
+
+	private CreatorSession onLocalInvite(Transaction txn, CreatorSession s,
+			@Nullable String message, long timestamp, byte[] signature)
+			throws DbException {
+		// Send an INVITE message
+		Message sent = sendInviteMessage(txn, s, message, timestamp, signature);
+		long localTimestamp = Math.max(timestamp, getLocalTimestamp(s));
+		// Move to the INVITED state
+		return new CreatorSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), localTimestamp,
+				timestamp, INVITED);
+	}
+
+	private CreatorSession onLocalLeave(Transaction txn, CreatorSession s)
+			throws DbException {
+		try {
+			// Stop syncing the private group with the contact
+			syncPrivateGroupWithContact(txn, s, false);
+		} catch (FormatException e) {
+			throw new DbException(e); // Invalid group metadata
+		}
+		// Send a LEAVE message
+		Message sent = sendLeaveMessage(txn, s, false);
+		// Move to the DISSOLVED state
+		return new CreatorSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				s.getInviteTimestamp(), DISSOLVED);
+	}
+
+	private CreatorSession onRemoteAccept(Transaction txn, CreatorSession s,
+			JoinMessage m) throws DbException, FormatException {
+		// The timestamp must be higher than the last invite message
+		if (m.getTimestamp() <= s.getInviteTimestamp()) return abort(txn, s);
+		// The dependency, if any, must be the last remote message
+		if (!isValidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+		// Mark the response visible in the UI
+		markMessageVisibleInUi(txn, m.getId(), true);
+		// Start syncing the private group with the contact
+		syncPrivateGroupWithContact(txn, s, true);
+		// Broadcast an event
+		ContactId contactId = getContactId(txn, m.getContactGroupId());
+		txn.attach(new GroupInvitationResponseReceivedEvent(contactId,
+				createInvitationResponse(m, contactId, true)));
+		// Move to the INVITEE_JOINED state
+		return new CreatorSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
+				s.getInviteTimestamp(), INVITEE_JOINED);
+	}
+
+	private CreatorSession onRemoteDecline(Transaction txn, CreatorSession s,
+			LeaveMessage m) throws DbException, FormatException {
+		// The timestamp must be higher than the last invite message
+		if (m.getTimestamp() <= s.getInviteTimestamp()) return abort(txn, s);
+		// The dependency, if any, must be the last remote message
+		if (!isValidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+		// Mark the response visible in the UI
+		markMessageVisibleInUi(txn, m.getId(), true);
+		// Broadcast an event
+		ContactId contactId = getContactId(txn, m.getContactGroupId());
+		txn.attach(new GroupInvitationResponseReceivedEvent(contactId,
+				createInvitationResponse(m, contactId, false)));
+		// Move to the START state
+		return new CreatorSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
+				s.getInviteTimestamp(), START);
+	}
+
+	private CreatorSession onRemoteLeave(Transaction txn, CreatorSession s,
+			LeaveMessage m) throws DbException, FormatException {
+		// The timestamp must be higher than the last invite message
+		if (m.getTimestamp() <= s.getInviteTimestamp()) return abort(txn, s);
+		// The dependency, if any, must be the last remote message
+		if (!isValidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+		// Stop syncing the private group with the contact
+		syncPrivateGroupWithContact(txn, s, false);
+		// Move to the INVITEE_LEFT state
+		return new CreatorSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
+				s.getInviteTimestamp(), INVITEE_LEFT);
+	}
+
+	private CreatorSession abort(Transaction txn, CreatorSession s)
+			throws DbException, FormatException {
+		// If the session has already been aborted, do nothing
+		if (s.getState() == ERROR) return s;
+		// If we subscribe, stop syncing the private group with the contact
+		if (isSubscribedPrivateGroup(txn, s.getPrivateGroupId()))
+			syncPrivateGroupWithContact(txn, s, false);
+		// Send an ABORT message
+		Message sent = sendAbortMessage(txn, s);
+		// Move to the ERROR state
+		return new CreatorSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				s.getInviteTimestamp(), ERROR);
+	}
+
+	private GroupInvitationResponse createInvitationResponse(
+			GroupInvitationMessage m, ContactId c, boolean accept) {
+		SessionId sessionId = new SessionId(m.getPrivateGroupId().getBytes());
+		return new GroupInvitationResponse(m.getId(), sessionId,
+				m.getContactGroupId(), c, accept, m.getTimestamp(), false,
+				false, true, false);
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/CreatorSession.java b/briar-core/src/org/briarproject/privategroup/invitation/CreatorSession.java
new file mode 100644
index 0000000000000000000000000000000000000000..62b6a05f730826a3b1cfb1fa24b9664b6b3d3509
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/CreatorSession.java
@@ -0,0 +1,41 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import static org.briarproject.privategroup.invitation.CreatorState.START;
+import static org.briarproject.privategroup.invitation.Role.CREATOR;
+
+@Immutable
+@NotNullByDefault
+class CreatorSession extends Session<CreatorState> {
+
+	private final CreatorState state;
+
+	CreatorSession(GroupId contactGroupId, GroupId privateGroupId,
+			@Nullable MessageId lastLocalMessageId,
+			@Nullable MessageId lastRemoteMessageId, long localTimestamp,
+			long inviteTimestamp, CreatorState state) {
+		super(contactGroupId, privateGroupId, lastLocalMessageId,
+				lastRemoteMessageId, localTimestamp, inviteTimestamp);
+		this.state = state;
+	}
+
+	CreatorSession(GroupId contactGroupId, GroupId privateGroupId) {
+		this(contactGroupId, privateGroupId, null, null, 0, 0, START);
+	}
+
+	@Override
+	Role getRole() {
+		return CREATOR;
+	}
+
+	@Override
+	CreatorState getState() {
+		return state;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/CreatorState.java b/briar-core/src/org/briarproject/privategroup/invitation/CreatorState.java
new file mode 100644
index 0000000000000000000000000000000000000000..3a54b00a37bc65e52e99f303547a5360c766e5bc
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/CreatorState.java
@@ -0,0 +1,25 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+
+enum CreatorState implements State {
+
+	START(0), INVITED(1), INVITEE_JOINED(2), INVITEE_LEFT(3), DISSOLVED(4),
+	ERROR(5);
+
+	private final int value;
+
+	CreatorState(int value) {
+		this.value = value;
+	}
+
+	@Override
+	public int getValue() {
+		return value;
+	}
+
+	static CreatorState fromValue(int value) throws FormatException {
+		for (CreatorState s : values()) if (s.value == value) return s;
+		throw new FormatException();
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationConstants.java b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..7cfe17f2dd09dcf92e00e78d75cc7fd48f281872
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationConstants.java
@@ -0,0 +1,25 @@
+package org.briarproject.privategroup.invitation;
+
+interface GroupInvitationConstants {
+
+	// Group metadata keys
+	String GROUP_KEY_CONTACT_ID = "contactId";
+
+	// Message metadata keys
+	String MSG_KEY_MESSAGE_TYPE = "messageType";
+	String MSG_KEY_PRIVATE_GROUP_ID = "privateGroupId";
+	String MSG_KEY_TIMESTAMP = "timestamp";
+	String MSG_KEY_LOCAL = "local";
+	String MSG_KEY_VISIBLE_IN_UI = "visibleInUi";
+	String MSG_KEY_AVAILABLE_TO_ANSWER = "availableToAnswer";
+
+	// Session keys
+	String SESSION_KEY_SESSION_ID = "sessionId";
+	String SESSION_KEY_PRIVATE_GROUP_ID = "privateGroupId";
+	String SESSION_KEY_LAST_LOCAL_MESSAGE_ID = "lastLocalMessageId";
+	String SESSION_KEY_LAST_REMOTE_MESSAGE_ID = "lastRemoteMessageId";
+	String SESSION_KEY_LOCAL_TIMESTAMP = "localTimestamp";
+	String SESSION_KEY_INVITE_TIMESTAMP = "inviteTimestamp";
+	String SESSION_KEY_ROLE = "role";
+	String SESSION_KEY_STATE = "state";
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationFactoryImpl.java b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationFactoryImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..3e9b8c88c67aaab47b7d9ab8c56e491c4ae93109
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationFactoryImpl.java
@@ -0,0 +1,60 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.clients.ContactGroupFactory;
+import org.briarproject.api.contact.Contact;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.privategroup.invitation.GroupInvitationFactory;
+import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupId;
+
+import java.security.GeneralSecurityException;
+
+import javax.inject.Inject;
+
+import static org.briarproject.api.privategroup.invitation.GroupInvitationManager.CLIENT_ID;
+
+class GroupInvitationFactoryImpl implements GroupInvitationFactory {
+
+	private final ContactGroupFactory contactGroupFactory;
+	private final ClientHelper clientHelper;
+
+	@Inject
+	GroupInvitationFactoryImpl(ContactGroupFactory contactGroupFactory,
+			ClientHelper clientHelper) {
+		this.contactGroupFactory = contactGroupFactory;
+		this.clientHelper = clientHelper;
+	}
+
+	@Override
+	public byte[] signInvitation(Contact c, GroupId privateGroupId,
+			long timestamp, byte[] privateKey) {
+		AuthorId creatorId = c.getLocalAuthorId();
+		AuthorId memberId = c.getAuthor().getId();
+		BdfList token = createInviteToken(creatorId, memberId, privateGroupId,
+				timestamp);
+		try {
+			return clientHelper.sign(token, privateKey);
+		} catch (GeneralSecurityException e) {
+			throw new IllegalArgumentException(e);
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	@Override
+	public BdfList createInviteToken(AuthorId creatorId, AuthorId memberId,
+			GroupId privateGroupId, long timestamp) {
+		Group contactGroup = contactGroupFactory.createContactGroup(CLIENT_ID,
+				creatorId, memberId);
+		return BdfList.of(
+				0, // TODO: Replace with a namespaced string
+				timestamp,
+				contactGroup.getId(),
+				privateGroupId
+		);
+	}
+
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationManagerImpl.java b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationManagerImpl.java
index f7dba6e1f1e7955c5514f7b0f0608da6399814e7..ae11b48b850f13154046c447614061e910e8dd4c 100644
--- a/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationManagerImpl.java
+++ b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationManagerImpl.java
@@ -7,44 +7,89 @@ import org.briarproject.api.clients.ContactGroupFactory;
 import org.briarproject.api.clients.SessionId;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
-import org.briarproject.api.contact.ContactManager;
+import org.briarproject.api.contact.ContactManager.AddContactHook;
+import org.briarproject.api.contact.ContactManager.RemoveContactHook;
 import org.briarproject.api.data.BdfDictionary;
 import org.briarproject.api.data.BdfList;
 import org.briarproject.api.data.MetadataParser;
 import org.briarproject.api.db.DatabaseComponent;
 import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Metadata;
 import org.briarproject.api.db.Transaction;
-import org.briarproject.api.messaging.ConversationManager;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.nullsafety.NotNullByDefault;
 import org.briarproject.api.privategroup.PrivateGroup;
+import org.briarproject.api.privategroup.PrivateGroupFactory;
+import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.privategroup.PrivateGroupManager.PrivateGroupHook;
 import org.briarproject.api.privategroup.invitation.GroupInvitationItem;
 import org.briarproject.api.privategroup.invitation.GroupInvitationManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationRequest;
+import org.briarproject.api.privategroup.invitation.GroupInvitationResponse;
 import org.briarproject.api.sharing.InvitationMessage;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.sync.MessageStatus;
 import org.briarproject.clients.ConversationClientImpl;
+import org.jetbrains.annotations.Nullable;
 
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
 
+import javax.annotation.concurrent.Immutable;
 import javax.inject.Inject;
 
-import static org.briarproject.api.privategroup.invitation.GroupInvitationConstants.CONTACT_ID;
+import static org.briarproject.privategroup.invitation.CreatorState.START;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.GROUP_KEY_CONTACT_ID;
+import static org.briarproject.privategroup.invitation.MessageType.ABORT;
+import static org.briarproject.privategroup.invitation.MessageType.INVITE;
+import static org.briarproject.privategroup.invitation.MessageType.JOIN;
+import static org.briarproject.privategroup.invitation.MessageType.LEAVE;
+import static org.briarproject.privategroup.invitation.Role.CREATOR;
+import static org.briarproject.privategroup.invitation.Role.INVITEE;
+import static org.briarproject.privategroup.invitation.Role.PEER;
 
-public class GroupInvitationManagerImpl extends ConversationClientImpl
-		implements GroupInvitationManager, Client,
-		ContactManager.AddContactHook, ContactManager.RemoveContactHook,
-		ConversationManager.ConversationClient {
+@Immutable
+@NotNullByDefault
+class GroupInvitationManagerImpl extends ConversationClientImpl
+		implements GroupInvitationManager, Client, AddContactHook,
+		RemoveContactHook, PrivateGroupHook {
 
 	private final ContactGroupFactory contactGroupFactory;
+	private final PrivateGroupFactory privateGroupFactory;
+	private final PrivateGroupManager privateGroupManager;
+	private final MessageParser messageParser;
+	private final SessionParser sessionParser;
+	private final SessionEncoder sessionEncoder;
+	private final ProtocolEngine<CreatorSession> creatorEngine;
+	private final ProtocolEngine<InviteeSession> inviteeEngine;
+	private final ProtocolEngine<PeerSession> peerEngine;
 	private final Group localGroup;
 
 	@Inject
 	protected GroupInvitationManagerImpl(DatabaseComponent db,
 			ClientHelper clientHelper, MetadataParser metadataParser,
-			ContactGroupFactory contactGroupFactory) {
+			ContactGroupFactory contactGroupFactory,
+			PrivateGroupFactory privateGroupFactory,
+			PrivateGroupManager privateGroupManager,
+			MessageParser messageParser, SessionParser sessionParser,
+			SessionEncoder sessionEncoder,
+			ProtocolEngineFactory engineFactory) {
 		super(db, clientHelper, metadataParser);
 		this.contactGroupFactory = contactGroupFactory;
+		this.privateGroupFactory = privateGroupFactory;
+		this.privateGroupManager = privateGroupManager;
+		this.messageParser = messageParser;
+		this.sessionParser = sessionParser;
+		this.sessionEncoder = sessionEncoder;
+		creatorEngine = engineFactory.createCreatorEngine();
+		inviteeEngine = engineFactory.createInviteeEngine();
+		peerEngine = engineFactory.createPeerEngine();
 		localGroup = contactGroupFactory.createLocalGroup(CLIENT_ID);
 	}
 
@@ -57,26 +102,31 @@ public class GroupInvitationManagerImpl extends ConversationClientImpl
 
 	@Override
 	public void addingContact(Transaction txn, Contact c) throws DbException {
+		// Create a group to share with the contact
+		Group g = getContactGroup(c);
+		// Return if we've already set things up for this contact
+		if (db.containsGroup(txn, g.getId())) return;
+		// Store the group and share it with the contact
+		db.addGroup(txn, g);
+		db.setVisibleToContact(txn, c.getId(), g.getId(), true);
+		// Attach the contact ID to the group
+		BdfDictionary meta = new BdfDictionary();
+		meta.put(GROUP_KEY_CONTACT_ID, c.getId().getInt());
 		try {
-			// Create a group to share with the contact
-			Group g = getContactGroup(c);
-			// Return if we've already set things up for this contact
-			if (db.containsGroup(txn, g.getId())) return;
-			// Store the group and share it with the contact
-			db.addGroup(txn, g);
-			db.setVisibleToContact(txn, c.getId(), g.getId(), true);
-			// Attach the contact ID to the group
-			BdfDictionary meta = new BdfDictionary();
-			meta.put(CONTACT_ID, c.getId().getInt());
 			clientHelper.mergeGroupMetadata(txn, g.getId(), meta);
 		} catch (FormatException e) {
-			throw new DbException(e);
+			throw new AssertionError(e);
+		}
+		// If the contact belongs to any private groups, create a peer session
+		for (Group pg : db.getGroups(txn, PrivateGroupManager.CLIENT_ID)) {
+			if (privateGroupManager.isMember(txn, pg.getId(), c.getAuthor()))
+				addingMember(txn, pg.getId(), c);
 		}
 	}
 
 	@Override
 	public void removingContact(Transaction txn, Contact c) throws DbException {
-		// remove the contact group (all messages will be removed with it)
+		// Remove the contact group (all messages will be removed with it)
 		db.removeGroup(txn, getContactGroup(c));
 	}
 
@@ -87,43 +137,416 @@ public class GroupInvitationManagerImpl extends ConversationClientImpl
 
 	@Override
 	protected boolean incomingMessage(Transaction txn, Message m, BdfList body,
-			BdfDictionary meta) throws DbException, FormatException {
+			BdfDictionary bdfMeta) throws DbException, FormatException {
+		// Parse the metadata
+		MessageMetadata meta = messageParser.parseMetadata(bdfMeta);
+		// Look up the session, if there is one
+		SessionId sessionId = getSessionId(meta.getPrivateGroupId());
+		StoredSession ss = getSession(txn, m.getGroupId(), sessionId);
+		// Handle the message
+		Session session;
+		MessageId storageId;
+		if (ss == null) {
+			session = handleFirstMessage(txn, m, body, meta);
+			storageId = createStorageId(txn, m.getGroupId());
+		} else {
+			session = handleMessage(txn, m, body, meta, ss.bdfSession);
+			storageId = ss.storageId;
+		}
+		// Store the updated session
+		storeSession(txn, storageId, session);
 		return false;
 	}
 
+	private SessionId getSessionId(GroupId privateGroupId) {
+		return new SessionId(privateGroupId.getBytes());
+	}
+
+	@Nullable
+	private StoredSession getSession(Transaction txn, GroupId contactGroupId,
+			SessionId sessionId) throws DbException, FormatException {
+		BdfDictionary query = sessionParser.getSessionQuery(sessionId);
+		Map<MessageId, BdfDictionary> results = clientHelper
+				.getMessageMetadataAsDictionary(txn, contactGroupId, query);
+		if (results.size() > 1) throw new DbException();
+		if (results.isEmpty()) return null;
+		return new StoredSession(results.keySet().iterator().next(),
+				results.values().iterator().next());
+	}
+
+	private Session handleFirstMessage(Transaction txn, Message m, BdfList body,
+			MessageMetadata meta) throws DbException, FormatException {
+		GroupId privateGroupId = meta.getPrivateGroupId();
+		MessageType type = meta.getMessageType();
+		if (type == INVITE) {
+			InviteeSession session =
+					new InviteeSession(m.getGroupId(), privateGroupId);
+			return handleMessage(txn, m, body, type, session, inviteeEngine);
+		} else if (type == JOIN) {
+			PeerSession session =
+					new PeerSession(m.getGroupId(), privateGroupId);
+			return handleMessage(txn, m, body, type, session, peerEngine);
+		} else {
+			throw new FormatException(); // Invalid first message
+		}
+	}
+
+	private Session handleMessage(Transaction txn, Message m, BdfList body,
+			MessageMetadata meta, BdfDictionary bdfSession)
+			throws DbException, FormatException {
+		MessageType type = meta.getMessageType();
+		Role role = sessionParser.getRole(bdfSession);
+		if (role == CREATOR) {
+			CreatorSession session = sessionParser
+					.parseCreatorSession(m.getGroupId(), bdfSession);
+			return handleMessage(txn, m, body, type, session, creatorEngine);
+		} else if (role == INVITEE) {
+			InviteeSession session = sessionParser
+					.parseInviteeSession(m.getGroupId(), bdfSession);
+			return handleMessage(txn, m, body, type, session, inviteeEngine);
+		} else if (role == PEER) {
+			PeerSession session = sessionParser
+					.parsePeerSession(m.getGroupId(), bdfSession);
+			return handleMessage(txn, m, body, type, session, peerEngine);
+		} else {
+			throw new AssertionError();
+		}
+	}
+
+	private <S extends Session> S handleMessage(Transaction txn, Message m,
+			BdfList body, MessageType type, S session, ProtocolEngine<S> engine)
+			throws DbException, FormatException {
+		if (type == INVITE) {
+			InviteMessage invite = messageParser.parseInviteMessage(m, body);
+			return engine.onInviteMessage(txn, session, invite);
+		} else if (type == JOIN) {
+			JoinMessage join = messageParser.parseJoinMessage(m, body);
+			return engine.onJoinMessage(txn, session, join);
+		} else if (type == LEAVE) {
+			LeaveMessage leave = messageParser.parseLeaveMessage(m, body);
+			return engine.onLeaveMessage(txn, session, leave);
+		} else if (type == ABORT) {
+			AbortMessage abort = messageParser.parseAbortMessage(m, body);
+			return engine.onAbortMessage(txn, session, abort);
+		} else {
+			throw new AssertionError();
+		}
+	}
+
+	private MessageId createStorageId(Transaction txn, GroupId g)
+			throws DbException {
+		Message m = clientHelper.createMessageForStoringMetadata(g);
+		db.addLocalMessage(txn, m, new Metadata(), false);
+		return m.getId();
+	}
+
+	private void storeSession(Transaction txn, MessageId storageId,
+			Session session) throws DbException, FormatException {
+		BdfDictionary d = sessionEncoder.encodeSession(session);
+		clientHelper.mergeMessageMetadata(txn, storageId, d);
+	}
+
+	@Override
+	public void sendInvitation(GroupId privateGroupId, ContactId c,
+			@Nullable String message, long timestamp, byte[] signature)
+			throws DbException {
+		SessionId sessionId = getSessionId(privateGroupId);
+		Transaction txn = db.startTransaction(false);
+		try {
+			// Look up the session, if there is one
+			Contact contact = db.getContact(txn, c);
+			GroupId contactGroupId = getContactGroup(contact).getId();
+			StoredSession ss = getSession(txn, contactGroupId, sessionId);
+			// Create or parse the session
+			CreatorSession session;
+			MessageId storageId;
+			if (ss == null) {
+				// This is the first invite - create a new session
+				session = new CreatorSession(contactGroupId, privateGroupId);
+				storageId = createStorageId(txn, contactGroupId);
+			} else {
+				// An earlier invite was declined, so we already have a session
+				session = sessionParser
+						.parseCreatorSession(contactGroupId, ss.bdfSession);
+				storageId = ss.storageId;
+			}
+			// Handle the invite action
+			session = creatorEngine.onInviteAction(txn, session, message,
+					timestamp, signature);
+			// Store the updated session
+			storeSession(txn, storageId, session);
+			db.commitTransaction(txn);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		} finally {
+			db.endTransaction(txn);
+		}
+	}
+
+	@Override
+	public void respondToInvitation(ContactId c, PrivateGroup g, boolean accept)
+			throws DbException {
+		respondToInvitation(c, getSessionId(g.getId()), accept);
+	}
+
 	@Override
-	public void sendInvitation(GroupId groupId, ContactId contactId,
-			String message) throws DbException {
+	public void respondToInvitation(ContactId c, SessionId sessionId,
+			boolean accept) throws DbException {
+		Transaction txn = db.startTransaction(false);
+		try {
+			// Look up the session
+			Contact contact = db.getContact(txn, c);
+			GroupId contactGroupId = getContactGroup(contact).getId();
+			StoredSession ss = getSession(txn, contactGroupId, sessionId);
+			if (ss == null) throw new IllegalArgumentException();
+			// Parse the session
+			InviteeSession session = sessionParser
+					.parseInviteeSession(contactGroupId, ss.bdfSession);
+			// Handle the join or leave action
+			if (accept) session = inviteeEngine.onJoinAction(txn, session);
+			else session = inviteeEngine.onLeaveAction(txn, session);
+			// Store the updated session
+			storeSession(txn, ss.storageId, session);
+			db.commitTransaction(txn);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		} finally {
+			db.endTransaction(txn);
+		}
+	}
 
+	private <S extends Session> S handleAction(Transaction txn,
+			LocalAction type, S session, ProtocolEngine<S> engine)
+			throws DbException, FormatException {
+		if (type == LocalAction.INVITE) {
+			throw new IllegalArgumentException();
+		} else if (type == LocalAction.JOIN) {
+			return engine.onJoinAction(txn, session);
+		} else if (type == LocalAction.LEAVE) {
+			return engine.onLeaveAction(txn, session);
+		} else if (type == LocalAction.MEMBER_ADDED) {
+			return engine.onMemberAddedAction(txn, session);
+		} else {
+			throw new AssertionError();
+		}
 	}
 
 	@Override
-	public void respondToInvitation(PrivateGroup g, Contact c, boolean accept)
+	public Collection<InvitationMessage> getInvitationMessages(ContactId c)
 			throws DbException {
+		List<InvitationMessage> messages;
+		Transaction txn = db.startTransaction(true);
+		try {
+			Contact contact = db.getContact(txn, c);
+			GroupId contactGroupId = getContactGroup(contact).getId();
+			BdfDictionary query = messageParser.getMessagesVisibleInUiQuery();
+			Map<MessageId, BdfDictionary> results = clientHelper
+					.getMessageMetadataAsDictionary(txn, contactGroupId, query);
+			messages = new ArrayList<InvitationMessage>(results.size());
+			for (Entry<MessageId, BdfDictionary> e : results.entrySet()) {
+				MessageId m = e.getKey();
+				MessageMetadata meta =
+						messageParser.parseMetadata(e.getValue());
+				MessageStatus status = db.getMessageStatus(txn, c, m);
+				MessageType type = meta.getMessageType();
+				if (type == INVITE) {
+					messages.add(parseInvitationRequest(txn, c, contactGroupId,
+							m, meta, status));
+				} else if (type == JOIN) {
+					messages.add(parseInvitationResponse(c, contactGroupId, m,
+							meta, status, true));
+				} else if (type == LEAVE) {
+					messages.add(parseInvitationResponse(c, contactGroupId, m,
+							meta, status, false));
+				}
+			}
+			db.commitTransaction(txn);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		} finally {
+			db.endTransaction(txn);
+		}
+		return messages;
+	}
+
+	private GroupInvitationRequest parseInvitationRequest(Transaction txn,
+			ContactId c, GroupId contactGroupId, MessageId m,
+			MessageMetadata meta, MessageStatus status)
+			throws DbException, FormatException {
+		SessionId sessionId = getSessionId(meta.getPrivateGroupId());
+		// Look up the invite message to get the details of the private group
+		InviteMessage invite = getInviteMessage(txn, m);
+		return new GroupInvitationRequest(m, sessionId, contactGroupId, c,
+				invite.getMessage(), invite.getGroupName(), invite.getCreator(),
+				meta.isAvailableToAnswer(), meta.getTimestamp(), meta.isLocal(),
+				status.isSent(), status.isSeen(), meta.isRead());
+	}
+
+	private InviteMessage getInviteMessage(Transaction txn, MessageId m)
+			throws DbException, FormatException {
+		Message message = clientHelper.getMessage(txn, m);
+		if (message == null) throw new DbException();
+		BdfList body = clientHelper.toList(message);
+		return messageParser.parseInviteMessage(message, body);
+	}
+
+	private GroupInvitationResponse parseInvitationResponse(ContactId c,
+			GroupId contactGroupId, MessageId m, MessageMetadata meta,
+			MessageStatus status, boolean accept)
+			throws DbException, FormatException {
+		SessionId sessionId = getSessionId(meta.getPrivateGroupId());
+		return new GroupInvitationResponse(m, sessionId, contactGroupId, c,
+				accept, meta.getTimestamp(), meta.isLocal(), status.isSent(),
+				status.isSeen(), meta.isRead());
+	}
 
+	@Override
+	public Collection<GroupInvitationItem> getInvitations() throws DbException {
+		List<GroupInvitationItem> items = new ArrayList<GroupInvitationItem>();
+		BdfDictionary query = messageParser.getInvitesAvailableToAnswerQuery();
+		Transaction txn = db.startTransaction(true);
+		try {
+			// Look up the available invite messages for each contact
+			for (Contact c : db.getContacts(txn)) {
+				GroupId contactGroupId = getContactGroup(c).getId();
+				Map<MessageId, BdfDictionary> results =
+						clientHelper.getMessageMetadataAsDictionary(txn,
+								contactGroupId, query);
+				for (MessageId m : results.keySet())
+					items.add(parseGroupInvitationItem(txn, c, m));
+			}
+			db.commitTransaction(txn);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		} finally {
+			db.endTransaction(txn);
+		}
+		return items;
 	}
 
 	@Override
-	public void respondToInvitation(SessionId id, boolean accept)
+	public boolean isInvitationAllowed(Contact c, GroupId privateGroupId)
 			throws DbException {
+		GroupId contactGroupId = getContactGroup(c).getId();
+		SessionId sessionId = getSessionId(privateGroupId);
+		Transaction txn = db.startTransaction(true);
+		try {
+			StoredSession ss = getSession(txn, contactGroupId, sessionId);
+			db.commitTransaction(txn);
+			// If there's no session, the contact can be invited
+			if (ss == null) return true;
+			// If there's a session, it should be a creator session
+			if (sessionParser.getRole(ss.bdfSession) != CREATOR)
+				throw new IllegalArgumentException();
+			// If the session's in the start state, the contact can be invited
+			CreatorSession session = sessionParser
+					.parseCreatorSession(contactGroupId, ss.bdfSession);
+			return session.getState() == START;
+		} catch (FormatException e) {
+			throw new DbException(e);
+		} finally {
+			db.endTransaction(txn);
+		}
+	}
 
+	private GroupInvitationItem parseGroupInvitationItem(Transaction txn,
+			Contact c, MessageId m) throws DbException, FormatException {
+		InviteMessage invite = getInviteMessage(txn, m);
+		PrivateGroup privateGroup = privateGroupFactory.createPrivateGroup(
+				invite.getGroupName(), invite.getCreator(), invite.getSalt());
+		return new GroupInvitationItem(privateGroup, c);
 	}
 
 	@Override
-	public Collection<InvitationMessage> getInvitationMessages(
-			ContactId contactId) throws DbException {
-		Collection<InvitationMessage> invitations =
-				new ArrayList<InvitationMessage>();
+	public void addingMember(Transaction txn, GroupId privateGroupId, Author a)
+			throws DbException {
+		// If the member is a contact, handle the add member action
+		for (Contact c : db.getContactsByAuthorId(txn, a.getId()))
+			addingMember(txn, privateGroupId, c);
+	}
 
-		return invitations;
+	private void addingMember(Transaction txn, GroupId privateGroupId,
+			Contact c) throws DbException {
+		try {
+			// Look up the session for the contact, if there is one
+			GroupId contactGroupId = getContactGroup(c).getId();
+			SessionId sessionId = getSessionId(privateGroupId);
+			StoredSession ss = getSession(txn, contactGroupId, sessionId);
+			// Create or parse the session
+			Session session;
+			MessageId storageId;
+			if (ss == null) {
+				// If there's no session the contact must be a peer,
+				// otherwise we would have exchanged invitation messages
+				PeerSession peerSession =
+						new PeerSession(contactGroupId, privateGroupId);
+				// Handle the action
+				session = peerEngine.onMemberAddedAction(txn, peerSession);
+				storageId = createStorageId(txn, contactGroupId);
+			} else {
+				// Handle the action
+				session = handleAction(txn, LocalAction.MEMBER_ADDED,
+						contactGroupId, ss.bdfSession);
+				storageId = ss.storageId;
+			}
+			// Store the updated session
+			storeSession(txn, storageId, session);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		}
 	}
 
 	@Override
-	public Collection<GroupInvitationItem> getInvitations() throws DbException {
-		Collection<GroupInvitationItem> invitations =
-				new ArrayList<GroupInvitationItem>();
+	public void removingGroup(Transaction txn, GroupId privateGroupId)
+			throws DbException {
+		SessionId sessionId = getSessionId(privateGroupId);
+		// If we have any sessions in progress, tell the contacts we're leaving
+		try {
+			for (Contact c : db.getContacts(txn)) {
+				// Look up the session for the contact, if there is one
+				GroupId contactGroupId = getContactGroup(c).getId();
+				StoredSession ss = getSession(txn, contactGroupId, sessionId);
+				if (ss == null) continue; // No session for this contact
+				// Handle the action
+				Session session = handleAction(txn, LocalAction.LEAVE,
+						contactGroupId, ss.bdfSession);
+				// Store the updated session
+				storeSession(txn, ss.storageId, session);
+			}
+		} catch (FormatException e) {
+			throw new DbException(e);
+		}
+	}
 
-		return invitations;
+	private Session handleAction(Transaction txn, LocalAction a,
+			GroupId contactGroupId, BdfDictionary bdfSession)
+			throws DbException, FormatException {
+		Role role = sessionParser.getRole(bdfSession);
+		if (role == CREATOR) {
+			CreatorSession session = sessionParser
+					.parseCreatorSession(contactGroupId, bdfSession);
+			return handleAction(txn, a, session, creatorEngine);
+		} else if (role == INVITEE) {
+			InviteeSession session = sessionParser
+					.parseInviteeSession(contactGroupId, bdfSession);
+			return handleAction(txn, a, session, inviteeEngine);
+		} else if (role == PEER) {
+			PeerSession session = sessionParser
+					.parsePeerSession(contactGroupId, bdfSession);
+			return handleAction(txn, a, session, peerEngine);
+		} else {
+			throw new AssertionError();
+		}
 	}
 
+	private static class StoredSession {
+
+		private final MessageId storageId;
+		private final BdfDictionary bdfSession;
+
+		private StoredSession(MessageId storageId, BdfDictionary bdfSession) {
+			this.storageId = storageId;
+			this.bdfSession = bdfSession;
+		}
+	}
 }
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationMessage.java b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..9ab719cb04d10317143ea76b8a8276f803a014d4
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationMessage.java
@@ -0,0 +1,40 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+abstract class GroupInvitationMessage {
+
+	private final MessageId id;
+	private final GroupId contactGroupId, privateGroupId;
+	private final long timestamp;
+
+	GroupInvitationMessage(MessageId id, GroupId contactGroupId,
+			GroupId privateGroupId, long timestamp) {
+		this.id = id;
+		this.contactGroupId = contactGroupId;
+		this.privateGroupId = privateGroupId;
+		this.timestamp = timestamp;
+	}
+
+	MessageId getId() {
+		return id;
+	}
+
+	GroupId getContactGroupId() {
+		return contactGroupId;
+	}
+
+	GroupId getPrivateGroupId() {
+		return privateGroupId;
+	}
+
+	long getTimestamp() {
+		return timestamp;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationModule.java b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationModule.java
new file mode 100644
index 0000000000000000000000000000000000000000..5e2733806dcbf45fe00858719cae408933446b17
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationModule.java
@@ -0,0 +1,98 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.contact.ContactManager;
+import org.briarproject.api.data.MetadataEncoder;
+import org.briarproject.api.identity.AuthorFactory;
+import org.briarproject.api.lifecycle.LifecycleManager;
+import org.briarproject.api.messaging.ConversationManager;
+import org.briarproject.api.privategroup.PrivateGroupFactory;
+import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationFactory;
+import org.briarproject.api.privategroup.invitation.GroupInvitationManager;
+import org.briarproject.api.sync.ValidationManager;
+import org.briarproject.api.system.Clock;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+import dagger.Module;
+import dagger.Provides;
+
+import static org.briarproject.api.privategroup.invitation.GroupInvitationManager.CLIENT_ID;
+
+@Module
+public class GroupInvitationModule {
+
+	public static class EagerSingletons {
+		@Inject
+		GroupInvitationValidator groupInvitationValidator;
+		@Inject
+		GroupInvitationManager groupInvitationManager;
+	}
+
+	@Provides
+	@Singleton
+	GroupInvitationManager provideGroupInvitationManager(
+			GroupInvitationManagerImpl groupInvitationManager,
+			LifecycleManager lifecycleManager,
+			ValidationManager validationManager, ContactManager contactManager,
+			PrivateGroupManager privateGroupManager,
+			ConversationManager conversationManager) {
+		lifecycleManager.registerClient(groupInvitationManager);
+		validationManager.registerIncomingMessageHook(CLIENT_ID,
+				groupInvitationManager);
+		contactManager.registerAddContactHook(groupInvitationManager);
+		contactManager.registerRemoveContactHook(groupInvitationManager);
+		privateGroupManager.registerPrivateGroupHook(groupInvitationManager);
+		conversationManager.registerConversationClient(groupInvitationManager);
+		return groupInvitationManager;
+	}
+
+	@Provides
+	@Singleton
+	GroupInvitationValidator provideGroupInvitationValidator(
+			ClientHelper clientHelper, MetadataEncoder metadataEncoder,
+			Clock clock, AuthorFactory authorFactory,
+			PrivateGroupFactory privateGroupFactory,
+			MessageEncoder messageEncoder,
+			ValidationManager validationManager) {
+		GroupInvitationValidator validator = new GroupInvitationValidator(
+				clientHelper, metadataEncoder, clock, authorFactory,
+				privateGroupFactory, messageEncoder);
+		validationManager.registerMessageValidator(CLIENT_ID, validator);
+		return validator;
+	}
+
+	@Provides
+	GroupInvitationFactory provideGroupInvitationFactory(
+			GroupInvitationFactoryImpl groupInvitationFactory) {
+		return groupInvitationFactory;
+	}
+
+	@Provides
+	MessageParser provideMessageParser(MessageParserImpl messageParser) {
+		return messageParser;
+	}
+
+	@Provides
+	MessageEncoder provideMessageEncoder(MessageEncoderImpl messageEncoder) {
+		return messageEncoder;
+	}
+
+	@Provides
+	SessionParser provideSessionParser(SessionParserImpl sessionParser) {
+		return sessionParser;
+	}
+
+	@Provides
+	SessionEncoder provideSessionEncoder(SessionEncoderImpl sessionEncoder) {
+		return sessionEncoder;
+	}
+
+	@Provides
+	ProtocolEngineFactory provideProtocolEngineFactory(
+			ProtocolEngineFactoryImpl protocolEngineFactory) {
+		return protocolEngineFactory;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationValidator.java b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationValidator.java
new file mode 100644
index 0000000000000000000000000000000000000000..15de431499b54f4c3abd969a6626ccf348d392cb
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/GroupInvitationValidator.java
@@ -0,0 +1,164 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.UniqueId;
+import org.briarproject.api.clients.BdfMessageContext;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.data.MetadataEncoder;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorFactory;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.privategroup.PrivateGroup;
+import org.briarproject.api.privategroup.PrivateGroupFactory;
+import org.briarproject.api.sync.Group;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.system.Clock;
+import org.briarproject.clients.BdfMessageValidator;
+
+import java.security.GeneralSecurityException;
+import java.util.Collections;
+
+import javax.annotation.concurrent.Immutable;
+import javax.inject.Inject;
+
+import static org.briarproject.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
+import static org.briarproject.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
+import static org.briarproject.api.identity.AuthorConstants.MAX_SIGNATURE_LENGTH;
+import static org.briarproject.api.privategroup.PrivateGroupConstants.GROUP_SALT_LENGTH;
+import static org.briarproject.api.privategroup.PrivateGroupConstants.MAX_GROUP_INVITATION_MSG_LENGTH;
+import static org.briarproject.api.privategroup.PrivateGroupConstants.MAX_GROUP_NAME_LENGTH;
+import static org.briarproject.privategroup.invitation.MessageType.ABORT;
+import static org.briarproject.privategroup.invitation.MessageType.INVITE;
+import static org.briarproject.privategroup.invitation.MessageType.JOIN;
+import static org.briarproject.privategroup.invitation.MessageType.LEAVE;
+
+@Immutable
+@NotNullByDefault
+class GroupInvitationValidator extends BdfMessageValidator {
+
+	private final AuthorFactory authorFactory;
+	private final PrivateGroupFactory privateGroupFactory;
+	private final MessageEncoder messageEncoder;
+
+	@Inject
+	GroupInvitationValidator(ClientHelper clientHelper,
+			MetadataEncoder metadataEncoder, Clock clock,
+			AuthorFactory authorFactory,
+			PrivateGroupFactory privateGroupFactory,
+			MessageEncoder messageEncoder) {
+		super(clientHelper, metadataEncoder, clock);
+		this.authorFactory = authorFactory;
+		this.privateGroupFactory = privateGroupFactory;
+		this.messageEncoder = messageEncoder;
+	}
+
+	@Override
+	protected BdfMessageContext validateMessage(Message m, Group g,
+			BdfList body) throws FormatException {
+		MessageType type = MessageType.fromValue(body.getLong(0).intValue());
+		switch (type) {
+			case INVITE:
+				return validateInviteMessage(m, body);
+			case JOIN:
+				return validateJoinMessage(m, body);
+			case LEAVE:
+				return validateLeaveMessage(m, body);
+			case ABORT:
+				return validateAbortMessage(m, body);
+			default:
+				throw new FormatException();
+		}
+	}
+
+	private BdfMessageContext validateInviteMessage(Message m, BdfList body)
+			throws FormatException {
+		checkSize(body, 7);
+		String groupName = body.getString(1);
+		checkLength(groupName, 1, MAX_GROUP_NAME_LENGTH);
+		String creatorName = body.getString(2);
+		checkLength(creatorName, 1, MAX_AUTHOR_NAME_LENGTH);
+		byte[] creatorPublicKey = body.getRaw(3);
+		checkLength(creatorPublicKey, 1, MAX_PUBLIC_KEY_LENGTH);
+		byte[] salt = body.getRaw(4);
+		checkLength(salt, GROUP_SALT_LENGTH);
+		String message = body.getOptionalString(5);
+		checkLength(message, 1, MAX_GROUP_INVITATION_MSG_LENGTH);
+		byte[] signature = body.getRaw(6);
+		checkLength(signature, 1, MAX_SIGNATURE_LENGTH);
+		// Create the private group
+		Author creator = authorFactory.createAuthor(creatorName,
+				creatorPublicKey);
+		PrivateGroup privateGroup = privateGroupFactory.createPrivateGroup(
+				groupName, creator, salt);
+		// Verify the signature
+		BdfList signed = BdfList.of(
+				INVITE.getValue(),
+				m.getTimestamp(),
+				m.getGroupId(),
+				privateGroup.getId()
+		);
+		try {
+			clientHelper.verifySignature(signature, creatorPublicKey, signed);
+		} catch (GeneralSecurityException e) {
+			throw new FormatException();
+		}
+		// Create the metadata
+		BdfDictionary meta = messageEncoder.encodeMetadata(INVITE,
+				privateGroup.getId(), m.getTimestamp(), false, false, false,
+				false);
+		return new BdfMessageContext(meta);
+	}
+
+	private BdfMessageContext validateJoinMessage(Message m, BdfList body)
+			throws FormatException {
+		checkSize(body, 3);
+		byte[] privateGroupId = body.getRaw(1);
+		checkLength(privateGroupId, UniqueId.LENGTH);
+		byte[] previousMessageId = body.getOptionalRaw(2);
+		checkLength(previousMessageId, UniqueId.LENGTH);
+		BdfDictionary meta = messageEncoder.encodeMetadata(JOIN,
+				new GroupId(privateGroupId), m.getTimestamp(), false, false,
+				false, false);
+		if (previousMessageId == null) {
+			return new BdfMessageContext(meta);
+		} else {
+			MessageId dependency = new MessageId(previousMessageId);
+			return new BdfMessageContext(meta,
+					Collections.singletonList(dependency));
+		}
+	}
+
+	private BdfMessageContext validateLeaveMessage(Message m, BdfList body)
+			throws FormatException {
+		checkSize(body, 3);
+		byte[] privateGroupId = body.getRaw(1);
+		checkLength(privateGroupId, UniqueId.LENGTH);
+		byte[] previousMessageId = body.getOptionalRaw(2);
+		checkLength(previousMessageId, UniqueId.LENGTH);
+		BdfDictionary meta = messageEncoder.encodeMetadata(LEAVE,
+				new GroupId(privateGroupId), m.getTimestamp(), false, false,
+				false, false);
+		if (previousMessageId == null) {
+			return new BdfMessageContext(meta);
+		} else {
+			MessageId dependency = new MessageId(previousMessageId);
+			return new BdfMessageContext(meta,
+					Collections.singletonList(dependency));
+		}
+	}
+
+	private BdfMessageContext validateAbortMessage(Message m, BdfList body)
+			throws FormatException {
+		checkSize(body, 2);
+		byte[] privateGroupId = body.getRaw(1);
+		checkLength(privateGroupId, UniqueId.LENGTH);
+		BdfDictionary meta = messageEncoder.encodeMetadata(ABORT,
+				new GroupId(privateGroupId), m.getTimestamp(), false, false,
+				false, false);
+		return new BdfMessageContext(meta);
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/InviteAction.java b/briar-core/src/org/briarproject/privategroup/invitation/InviteAction.java
new file mode 100644
index 0000000000000000000000000000000000000000..f43af230a80b3b2be49176e91fbe2770d8d5e761
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/InviteAction.java
@@ -0,0 +1,35 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.nullsafety.NotNullByDefault;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class InviteAction {
+
+	@Nullable
+	private final String message;
+	private final long timestamp;
+	private final byte[] signature;
+
+	InviteAction(@Nullable String message, long timestamp, byte[] signature) {
+		this.message = message;
+		this.timestamp = timestamp;
+		this.signature = signature;
+	}
+
+	@Nullable
+	String getMessage() {
+		return message;
+	}
+
+	long getTimestamp() {
+		return timestamp;
+	}
+
+	byte[] getSignature() {
+		return signature;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/InviteMessage.java b/briar-core/src/org/briarproject/privategroup/invitation/InviteMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..b286e8c09cb1f894678228a0c75986b809b3ae5b
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/InviteMessage.java
@@ -0,0 +1,52 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class InviteMessage extends GroupInvitationMessage {
+
+	private final String groupName;
+	private final Author creator;
+	private final byte[] salt, signature;
+	@Nullable
+	private final String message;
+
+	InviteMessage(MessageId id, GroupId contactGroupId, GroupId privateGroupId,
+			long timestamp, String groupName, Author creator, byte[] salt,
+			@Nullable String message, byte[] signature) {
+		super(id, contactGroupId, privateGroupId, timestamp);
+		this.groupName = groupName;
+		this.creator = creator;
+		this.salt = salt;
+		this.message = message;
+		this.signature = signature;
+	}
+
+	String getGroupName() {
+		return groupName;
+	}
+
+	Author getCreator() {
+		return creator;
+	}
+
+	byte[] getSalt() {
+		return salt;
+	}
+
+	@Nullable
+	String getMessage() {
+		return message;
+	}
+
+	byte[] getSignature() {
+		return signature;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/InviteeProtocolEngine.java b/briar-core/src/org/briarproject/privategroup/invitation/InviteeProtocolEngine.java
new file mode 100644
index 0000000000000000000000000000000000000000..1526e23bd923ac65e3c4257d6f68a01643379e0a
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/InviteeProtocolEngine.java
@@ -0,0 +1,260 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.clients.ProtocolStateException;
+import org.briarproject.api.clients.SessionId;
+import org.briarproject.api.contact.ContactId;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.event.GroupInvitationRequestReceivedEvent;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.privategroup.GroupMessageFactory;
+import org.briarproject.api.privategroup.PrivateGroup;
+import org.briarproject.api.privategroup.PrivateGroupFactory;
+import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.privategroup.invitation.GroupInvitationRequest;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.system.Clock;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import static org.briarproject.privategroup.invitation.InviteeState.DISSOLVED;
+import static org.briarproject.privategroup.invitation.InviteeState.ERROR;
+import static org.briarproject.privategroup.invitation.InviteeState.INVITED;
+import static org.briarproject.privategroup.invitation.InviteeState.INVITEE_JOINED;
+import static org.briarproject.privategroup.invitation.InviteeState.INVITEE_LEFT;
+import static org.briarproject.privategroup.invitation.InviteeState.START;
+
+@Immutable
+@NotNullByDefault
+class InviteeProtocolEngine extends AbstractProtocolEngine<InviteeSession> {
+
+	InviteeProtocolEngine(DatabaseComponent db, ClientHelper clientHelper,
+			PrivateGroupManager privateGroupManager,
+			PrivateGroupFactory privateGroupFactory,
+			GroupMessageFactory groupMessageFactory,
+			IdentityManager identityManager, MessageParser messageParser,
+			MessageEncoder messageEncoder, Clock clock) {
+		super(db, clientHelper, privateGroupManager, privateGroupFactory,
+				groupMessageFactory, identityManager, messageParser,
+				messageEncoder, clock);
+	}
+
+	@Override
+	public InviteeSession onInviteAction(Transaction txn, InviteeSession s,
+			@Nullable String message, long timestamp, byte[] signature)
+			throws DbException {
+		throw new UnsupportedOperationException(); // Invalid in this role
+	}
+
+	@Override
+	public InviteeSession onJoinAction(Transaction txn, InviteeSession s)
+			throws DbException {
+		switch (s.getState()) {
+			case START:
+			case INVITEE_JOINED:
+			case INVITEE_LEFT:
+			case DISSOLVED:
+			case ERROR:
+				throw new ProtocolStateException(); // Invalid in these states
+			case INVITED:
+				return onLocalAccept(txn, s);
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public InviteeSession onLeaveAction(Transaction txn, InviteeSession s)
+			throws DbException {
+		switch (s.getState()) {
+			case START:
+			case INVITEE_LEFT:
+			case DISSOLVED:
+			case ERROR:
+				return s; // Ignored in these states
+			case INVITED:
+				return onLocalDecline(txn, s);
+			case INVITEE_JOINED:
+				return onLocalLeave(txn, s);
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public InviteeSession onMemberAddedAction(Transaction txn, InviteeSession s)
+			throws DbException {
+		return s; // Ignored in this role
+	}
+
+	@Override
+	public InviteeSession onInviteMessage(Transaction txn, InviteeSession s,
+			InviteMessage m) throws DbException, FormatException {
+		switch (s.getState()) {
+			case START:
+				return onRemoteInvite(txn, s, m);
+			case INVITED:
+			case INVITEE_JOINED:
+			case INVITEE_LEFT:
+			case DISSOLVED:
+				return abort(txn, s); // Invalid in these states
+			case ERROR:
+				return s; // Ignored in this state
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public InviteeSession onJoinMessage(Transaction txn, InviteeSession s,
+			JoinMessage m) throws DbException, FormatException {
+		return abort(txn, s); // Invalid in this role
+	}
+
+	@Override
+	public InviteeSession onLeaveMessage(Transaction txn, InviteeSession s,
+			LeaveMessage m) throws DbException, FormatException {
+		switch (s.getState()) {
+			case START:
+			case DISSOLVED:
+				return abort(txn, s); // Invalid in these states
+			case INVITED:
+			case INVITEE_JOINED:
+			case INVITEE_LEFT:
+				return onRemoteLeave(txn, s, m);
+			case ERROR:
+				return s; // Ignored in this state
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public InviteeSession onAbortMessage(Transaction txn, InviteeSession s,
+			AbortMessage m) throws DbException, FormatException {
+		return abort(txn, s);
+	}
+
+	private InviteeSession onLocalAccept(Transaction txn, InviteeSession s)
+			throws DbException {
+		// Mark the invite message unavailable to answer
+		MessageId inviteId = s.getLastRemoteMessageId();
+		if (inviteId == null) throw new IllegalStateException();
+		markMessageAvailableToAnswer(txn, inviteId, false);
+		// Send a JOIN message
+		Message sent = sendJoinMessage(txn, s, true);
+		try {
+			// Subscribe to the private group
+			subscribeToPrivateGroup(txn, inviteId);
+			// Start syncing the private group with the contact
+			syncPrivateGroupWithContact(txn, s, true);
+		} catch (FormatException e) {
+			throw new DbException(e); // Invalid group metadata
+		}
+		// Move to the INVITEE_JOINED state
+		return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				s.getInviteTimestamp(), INVITEE_JOINED);
+	}
+
+	private InviteeSession onLocalDecline(Transaction txn, InviteeSession s)
+			throws DbException {
+		// Mark the invite message unavailable to answer
+		MessageId inviteId = s.getLastRemoteMessageId();
+		if (inviteId == null) throw new IllegalStateException();
+		markMessageAvailableToAnswer(txn, inviteId, false);
+		// Send a LEAVE message
+		Message sent = sendLeaveMessage(txn, s, true);
+		// Move to the START state
+		return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				s.getInviteTimestamp(), START);
+	}
+
+	private InviteeSession onLocalLeave(Transaction txn, InviteeSession s)
+			throws DbException {
+		// Send a LEAVE message
+		Message sent = sendLeaveMessage(txn, s, false);
+		// Move to the INVITEE_LEFT state
+		return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				s.getInviteTimestamp(), INVITEE_LEFT);
+	}
+
+	private InviteeSession onRemoteInvite(Transaction txn, InviteeSession s,
+			InviteMessage m) throws DbException, FormatException {
+		// The timestamp must be higher than the last invite message, if any
+		if (m.getTimestamp() <= s.getInviteTimestamp()) return abort(txn, s);
+		// Check that the contact is the creator
+		ContactId contactId = getContactId(txn, s.getContactGroupId());
+		Author contact = db.getContact(txn, contactId).getAuthor();
+		if (!contact.getId().equals(m.getCreator().getId()))
+			return abort(txn, s);
+		// Mark the invite message visible in the UI and available to answer
+		markMessageVisibleInUi(txn, m.getId(), true);
+		markMessageAvailableToAnswer(txn, m.getId(), true);
+		// Broadcast an event
+		PrivateGroup privateGroup = privateGroupFactory.createPrivateGroup(
+				m.getGroupName(), m.getCreator(), m.getSalt());
+		txn.attach(new GroupInvitationRequestReceivedEvent(privateGroup,
+				contactId, createInvitationRequest(m, contactId)));
+		// Move to the INVITED state
+		return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
+				m.getTimestamp(), INVITED);
+	}
+
+	private InviteeSession onRemoteLeave(Transaction txn, InviteeSession s,
+			LeaveMessage m) throws DbException, FormatException {
+		// The timestamp must be higher than the last invite message, if any
+		if (m.getTimestamp() <= s.getInviteTimestamp()) return abort(txn, s);
+		// The dependency, if any, must be the last remote message
+		if (!isValidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+		try {
+			// Stop syncing the private group with the contact
+			syncPrivateGroupWithContact(txn, s, false);
+		} catch (FormatException e) {
+			throw new DbException(e); // Invalid group metadata
+		}
+		// Mark the group dissolved
+		privateGroupManager.markGroupDissolved(txn, s.getPrivateGroupId());
+		// Move to the DISSOLVED state
+		return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
+				s.getInviteTimestamp(), DISSOLVED);
+	}
+
+	private InviteeSession abort(Transaction txn, InviteeSession s)
+			throws DbException, FormatException {
+		// If the session has already been aborted, do nothing
+		if (s.getState() == ERROR) return s;
+		// Mark any invite messages in the session unavailable to answer
+		markInvitesUnavailableToAnswer(txn, s);
+		// Stop syncing the private group with the contact, if we subscribe
+		if (isSubscribedPrivateGroup(txn, s.getPrivateGroupId()))
+			syncPrivateGroupWithContact(txn, s, false);
+		// Send an ABORT message
+		Message sent = sendAbortMessage(txn, s);
+		// Move to the ERROR state
+		return new InviteeSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				s.getInviteTimestamp(), ERROR);
+	}
+
+	private GroupInvitationRequest createInvitationRequest(InviteMessage m,
+			ContactId c) {
+		SessionId sessionId = new SessionId(m.getPrivateGroupId().getBytes());
+		return new GroupInvitationRequest(m.getId(), sessionId,
+				m.getContactGroupId(), c, m.getMessage(), m.getGroupName(),
+				m.getCreator(), true, m.getTimestamp(), false, false, true,
+				false);
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/InviteeSession.java b/briar-core/src/org/briarproject/privategroup/invitation/InviteeSession.java
new file mode 100644
index 0000000000000000000000000000000000000000..12730ccbdccd982bdd8bb7d02a472df48ab0aa84
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/InviteeSession.java
@@ -0,0 +1,41 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import static org.briarproject.privategroup.invitation.InviteeState.START;
+import static org.briarproject.privategroup.invitation.Role.INVITEE;
+
+@Immutable
+@NotNullByDefault
+class InviteeSession extends Session<InviteeState> {
+
+	private final InviteeState state;
+
+	InviteeSession(GroupId contactGroupId, GroupId privateGroupId,
+			@Nullable MessageId lastLocalMessageId,
+			@Nullable MessageId lastRemoteMessageId, long localTimestamp,
+			long inviteTimestamp, InviteeState state) {
+		super(contactGroupId, privateGroupId, lastLocalMessageId,
+				lastRemoteMessageId, localTimestamp, inviteTimestamp);
+		this.state = state;
+	}
+
+	InviteeSession(GroupId contactGroupId, GroupId privateGroupId) {
+		this(contactGroupId, privateGroupId, null, null, 0, 0, START);
+	}
+
+	@Override
+	Role getRole() {
+		return INVITEE;
+	}
+
+	@Override
+	InviteeState getState() {
+		return state;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/InviteeState.java b/briar-core/src/org/briarproject/privategroup/invitation/InviteeState.java
new file mode 100644
index 0000000000000000000000000000000000000000..97e6480a5d04a9b16846b8580fc55b19e1e34f6c
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/InviteeState.java
@@ -0,0 +1,25 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+
+enum InviteeState implements State {
+
+	START(0), INVITED(1), INVITEE_JOINED(2), INVITEE_LEFT(3), DISSOLVED(4),
+	ERROR(5);
+
+	private final int value;
+
+	InviteeState(int value) {
+		this.value = value;
+	}
+
+	@Override
+	public int getValue() {
+		return value;
+	}
+
+	static InviteeState fromValue(int value) throws FormatException {
+		for (InviteeState s : values()) if (s.value == value) return s;
+		throw new FormatException();
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/JoinMessage.java b/briar-core/src/org/briarproject/privategroup/invitation/JoinMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..7b9c7df179d3163dfcb14bb9e08470d02d5c046f
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/JoinMessage.java
@@ -0,0 +1,27 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+import org.jetbrains.annotations.Nullable;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class JoinMessage extends GroupInvitationMessage {
+
+	@Nullable
+	private final MessageId previousMessageId;
+
+	JoinMessage(MessageId id, GroupId contactGroupId, GroupId privateGroupId,
+			long timestamp, @Nullable MessageId previousMessageId) {
+		super(id, contactGroupId, privateGroupId, timestamp);
+		this.previousMessageId = previousMessageId;
+	}
+
+	@Nullable
+	MessageId getPreviousMessageId() {
+		return previousMessageId;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/LeaveMessage.java b/briar-core/src/org/briarproject/privategroup/invitation/LeaveMessage.java
new file mode 100644
index 0000000000000000000000000000000000000000..86abb687e49609ae73512b566f2e4bb1dd59531a
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/LeaveMessage.java
@@ -0,0 +1,27 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+import org.jetbrains.annotations.Nullable;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+class LeaveMessage extends GroupInvitationMessage {
+
+	@Nullable
+	private final MessageId previousMessageId;
+
+	LeaveMessage(MessageId id, GroupId contactGroupId, GroupId privateGroupId,
+			long timestamp, @Nullable MessageId previousMessageId) {
+		super(id, contactGroupId, privateGroupId, timestamp);
+		this.previousMessageId = previousMessageId;
+	}
+
+	@Nullable
+	MessageId getPreviousMessageId() {
+		return previousMessageId;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/LocalAction.java b/briar-core/src/org/briarproject/privategroup/invitation/LocalAction.java
new file mode 100644
index 0000000000000000000000000000000000000000..3d2532ab1878c97b628fffed51ecf866bfbc4bf2
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/LocalAction.java
@@ -0,0 +1,6 @@
+package org.briarproject.privategroup.invitation;
+
+enum LocalAction {
+
+	INVITE, JOIN, LEAVE, MEMBER_ADDED
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/MessageEncoder.java b/briar-core/src/org/briarproject/privategroup/invitation/MessageEncoder.java
new file mode 100644
index 0000000000000000000000000000000000000000..7ced5fbaa12a1cf2c2245aa408deb7c9f4fad8d6
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/MessageEncoder.java
@@ -0,0 +1,35 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.Nullable;
+
+@NotNullByDefault
+interface MessageEncoder {
+
+	BdfDictionary encodeMetadata(MessageType type, GroupId privateGroupId,
+			long timestamp, boolean local, boolean read, boolean visible,
+			boolean available);
+
+	void setVisibleInUi(BdfDictionary meta, boolean visible);
+
+	void setAvailableToAnswer(BdfDictionary meta, boolean available);
+
+	Message encodeInviteMessage(GroupId contactGroupId, GroupId privateGroupId,
+			long timestamp, String groupName, Author creator, byte[] salt,
+			@Nullable String message, byte[] signature);
+
+	Message encodeJoinMessage(GroupId contactGroupId, GroupId privateGroupId,
+			long timestamp, @Nullable MessageId previousMessageId);
+
+	Message encodeLeaveMessage(GroupId contactGroupId, GroupId privateGroupId,
+			long timestamp, @Nullable MessageId previousMessageId);
+
+	Message encodeAbortMessage(GroupId contactGroupId, GroupId privateGroupId,
+			long timestamp);
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/MessageEncoderImpl.java b/briar-core/src/org/briarproject/privategroup/invitation/MessageEncoderImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..18180fe81a998e4fcaa02a7d63ba61195585fa24
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/MessageEncoderImpl.java
@@ -0,0 +1,139 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageFactory;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+import javax.inject.Inject;
+
+import static org.briarproject.clients.BdfConstants.MSG_KEY_READ;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_AVAILABLE_TO_ANSWER;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_LOCAL;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_MESSAGE_TYPE;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_PRIVATE_GROUP_ID;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_TIMESTAMP;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_VISIBLE_IN_UI;
+import static org.briarproject.privategroup.invitation.MessageType.ABORT;
+import static org.briarproject.privategroup.invitation.MessageType.INVITE;
+import static org.briarproject.privategroup.invitation.MessageType.JOIN;
+import static org.briarproject.privategroup.invitation.MessageType.LEAVE;
+
+@Immutable
+@NotNullByDefault
+class MessageEncoderImpl implements MessageEncoder {
+
+	private final ClientHelper clientHelper;
+	private final MessageFactory messageFactory;
+
+	@Inject
+	MessageEncoderImpl(ClientHelper clientHelper,
+			MessageFactory messageFactory) {
+		this.clientHelper = clientHelper;
+		this.messageFactory = messageFactory;
+	}
+
+	@Override
+	public BdfDictionary encodeMetadata(MessageType type,
+			GroupId privateGroupId, long timestamp, boolean local, boolean read,
+			boolean visible, boolean available) {
+		BdfDictionary meta = new BdfDictionary();
+		meta.put(MSG_KEY_MESSAGE_TYPE, type.getValue());
+		meta.put(MSG_KEY_PRIVATE_GROUP_ID, privateGroupId);
+		meta.put(MSG_KEY_TIMESTAMP, timestamp);
+		meta.put(MSG_KEY_LOCAL, local);
+		meta.put(MSG_KEY_READ, read);
+		meta.put(MSG_KEY_VISIBLE_IN_UI, visible);
+		meta.put(MSG_KEY_AVAILABLE_TO_ANSWER, available);
+		return meta;
+	}
+
+	@Override
+	public void setVisibleInUi(BdfDictionary meta, boolean visible) {
+		meta.put(MSG_KEY_VISIBLE_IN_UI, visible);
+	}
+
+	@Override
+	public void setAvailableToAnswer(BdfDictionary meta, boolean available) {
+		meta.put(MSG_KEY_AVAILABLE_TO_ANSWER, available);
+	}
+
+	@Override
+	public Message encodeInviteMessage(GroupId contactGroupId,
+			GroupId privateGroupId, long timestamp, String groupName,
+			Author creator, byte[] salt, @Nullable String message,
+			byte[] signature) {
+		BdfList body = BdfList.of(
+				INVITE.getValue(),
+				groupName,
+				creator.getName(),
+				creator.getPublicKey(),
+				salt,
+				message,
+				signature
+		);
+		try {
+			return messageFactory.createMessage(contactGroupId, timestamp,
+					clientHelper.toByteArray(body));
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	@Override
+	public Message encodeJoinMessage(GroupId contactGroupId,
+			GroupId privateGroupId, long timestamp,
+			@Nullable MessageId previousMessageId) {
+		BdfList body = BdfList.of(
+				JOIN.getValue(),
+				privateGroupId,
+				previousMessageId
+		);
+		try {
+			return messageFactory.createMessage(contactGroupId, timestamp,
+					clientHelper.toByteArray(body));
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	@Override
+	public Message encodeLeaveMessage(GroupId contactGroupId,
+			GroupId privateGroupId, long timestamp,
+			@Nullable MessageId previousMessageId) {
+		BdfList body = BdfList.of(
+				LEAVE.getValue(),
+				privateGroupId,
+				previousMessageId
+		);
+		try {
+			return messageFactory.createMessage(contactGroupId, timestamp,
+					clientHelper.toByteArray(body));
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+
+	@Override
+	public Message encodeAbortMessage(GroupId contactGroupId,
+			GroupId privateGroupId, long timestamp) {
+		BdfList body = BdfList.of(
+				ABORT.getValue(),
+				privateGroupId
+		);
+		try {
+			return messageFactory.createMessage(contactGroupId, timestamp,
+					clientHelper.toByteArray(body));
+		} catch (FormatException e) {
+			throw new AssertionError(e);
+		}
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/MessageMetadata.java b/briar-core/src/org/briarproject/privategroup/invitation/MessageMetadata.java
new file mode 100644
index 0000000000000000000000000000000000000000..503a5e79db8e1a3e5e0cefd7f3ba4650c45ee9ac
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/MessageMetadata.java
@@ -0,0 +1,51 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.sync.GroupId;
+
+class MessageMetadata {
+
+	private final MessageType type;
+	private final GroupId privateGroupId;
+	private final long timestamp;
+	private final boolean local, read, visible, available;
+
+	MessageMetadata(MessageType type, GroupId privateGroupId,
+			long timestamp, boolean local, boolean read, boolean visible,
+			boolean available) {
+		this.privateGroupId = privateGroupId;
+		this.type = type;
+		this.timestamp = timestamp;
+		this.local = local;
+		this.read = read;
+		this.visible = visible;
+		this.available = available;
+	}
+
+	MessageType getMessageType() {
+		return type;
+	}
+
+	GroupId getPrivateGroupId() {
+		return privateGroupId;
+	}
+
+	long getTimestamp() {
+		return timestamp;
+	}
+
+	boolean isLocal() {
+		return local;
+	}
+
+	boolean isRead() {
+		return read;
+	}
+
+	boolean isVisibleInConversation() {
+		return visible;
+	}
+
+	boolean isAvailableToAnswer() {
+		return available;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/MessageParser.java b/briar-core/src/org/briarproject/privategroup/invitation/MessageParser.java
new file mode 100644
index 0000000000000000000000000000000000000000..c2e39d460609d3ea320b121b6671eb205f9ddd84
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/MessageParser.java
@@ -0,0 +1,33 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+
+@NotNullByDefault
+interface MessageParser {
+
+	BdfDictionary getMessagesVisibleInUiQuery();
+
+	BdfDictionary getInvitesAvailableToAnswerQuery();
+
+	BdfDictionary getInvitesAvailableToAnswerQuery(GroupId privateGroupId);
+
+	MessageMetadata parseMetadata(BdfDictionary meta) throws FormatException;
+
+	InviteMessage parseInviteMessage(Message m, BdfList body)
+			throws FormatException;
+
+	JoinMessage parseJoinMessage(Message m, BdfList body)
+			throws FormatException;
+
+	LeaveMessage parseLeaveMessage(Message m, BdfList body)
+			throws FormatException;
+
+	AbortMessage parseAbortMessage(Message m, BdfList body)
+			throws FormatException;
+
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/MessageParserImpl.java b/briar-core/src/org/briarproject/privategroup/invitation/MessageParserImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..13837cb36c98aac84ed1c612f08464e7bd577d12
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/MessageParserImpl.java
@@ -0,0 +1,127 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfEntry;
+import org.briarproject.api.data.BdfList;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorFactory;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.privategroup.PrivateGroup;
+import org.briarproject.api.privategroup.PrivateGroupFactory;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.concurrent.Immutable;
+import javax.inject.Inject;
+
+import static org.briarproject.clients.BdfConstants.MSG_KEY_READ;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_AVAILABLE_TO_ANSWER;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_LOCAL;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_MESSAGE_TYPE;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_PRIVATE_GROUP_ID;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_TIMESTAMP;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.MSG_KEY_VISIBLE_IN_UI;
+import static org.briarproject.privategroup.invitation.MessageType.INVITE;
+
+@Immutable
+@NotNullByDefault
+class MessageParserImpl implements MessageParser {
+
+	private final AuthorFactory authorFactory;
+	private final PrivateGroupFactory privateGroupFactory;
+
+	@Inject
+	MessageParserImpl(AuthorFactory authorFactory,
+			PrivateGroupFactory privateGroupFactory) {
+		this.authorFactory = authorFactory;
+		this.privateGroupFactory = privateGroupFactory;
+	}
+
+	@Override
+	public BdfDictionary getMessagesVisibleInUiQuery() {
+		return BdfDictionary.of(new BdfEntry(MSG_KEY_VISIBLE_IN_UI, true));
+	}
+
+	@Override
+	public BdfDictionary getInvitesAvailableToAnswerQuery() {
+		return BdfDictionary.of(
+				new BdfEntry(MSG_KEY_AVAILABLE_TO_ANSWER, true),
+				new BdfEntry(MSG_KEY_MESSAGE_TYPE, INVITE.getValue())
+		);
+	}
+
+	@Override
+	public BdfDictionary getInvitesAvailableToAnswerQuery(
+			GroupId privateGroupId) {
+		return BdfDictionary.of(
+				new BdfEntry(MSG_KEY_AVAILABLE_TO_ANSWER, true),
+				new BdfEntry(MSG_KEY_MESSAGE_TYPE, INVITE.getValue()),
+				new BdfEntry(MSG_KEY_PRIVATE_GROUP_ID, privateGroupId)
+		);
+	}
+
+	@Override
+	public MessageMetadata parseMetadata(BdfDictionary meta)
+			throws FormatException {
+		MessageType type = MessageType.fromValue(
+				meta.getLong(MSG_KEY_MESSAGE_TYPE).intValue());
+		GroupId privateGroupId =
+				new GroupId(meta.getRaw(MSG_KEY_PRIVATE_GROUP_ID));
+		long timestamp = meta.getLong(MSG_KEY_TIMESTAMP);
+		boolean local = meta.getBoolean(MSG_KEY_LOCAL);
+		boolean read = meta.getBoolean(MSG_KEY_READ, false);
+		boolean visible = meta.getBoolean(MSG_KEY_VISIBLE_IN_UI, false);
+		boolean available = meta.getBoolean(MSG_KEY_AVAILABLE_TO_ANSWER, false);
+		return new MessageMetadata(type, privateGroupId, timestamp, local, read,
+				visible, available);
+	}
+
+	@Override
+	public InviteMessage parseInviteMessage(Message m, BdfList body)
+			throws FormatException {
+		String groupName = body.getString(1);
+		String creatorName = body.getString(2);
+		byte[] creatorPublicKey = body.getRaw(3);
+		byte[] salt = body.getRaw(4);
+		String message = body.getOptionalString(5);
+		byte[] signature = body.getRaw(6);
+		Author creator = authorFactory.createAuthor(creatorName,
+				creatorPublicKey);
+		PrivateGroup privateGroup = privateGroupFactory.createPrivateGroup(
+				groupName, creator, salt);
+		return new InviteMessage(m.getId(), m.getGroupId(),
+				privateGroup.getId(), m.getTimestamp(), groupName, creator,
+				salt, message, signature);
+	}
+
+	@Override
+	public JoinMessage parseJoinMessage(Message m, BdfList body)
+			throws FormatException {
+		GroupId privateGroupId = new GroupId(body.getRaw(1));
+		byte[] b = body.getOptionalRaw(2);
+		MessageId previousMessageId = b == null ? null : new MessageId(b);
+		return new JoinMessage(m.getId(), m.getGroupId(), privateGroupId,
+				m.getTimestamp(), previousMessageId);
+	}
+
+	@Override
+	public LeaveMessage parseLeaveMessage(Message m, BdfList body)
+			throws FormatException {
+		GroupId privateGroupId = new GroupId(body.getRaw(1));
+		byte[] b = body.getOptionalRaw(2);
+		MessageId previousMessageId = b == null ? null : new MessageId(b);
+		return new LeaveMessage(m.getId(), m.getGroupId(), privateGroupId,
+				m.getTimestamp(), previousMessageId);
+	}
+
+	@Override
+	public AbortMessage parseAbortMessage(Message m, BdfList body)
+			throws FormatException {
+		GroupId privateGroupId = new GroupId(body.getRaw(1));
+		return new AbortMessage(m.getId(), m.getGroupId(), privateGroupId,
+				m.getTimestamp());
+	}
+
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/MessageType.java b/briar-core/src/org/briarproject/privategroup/invitation/MessageType.java
new file mode 100644
index 0000000000000000000000000000000000000000..a8f04a28746b2f993170e341460761c86b244a0e
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/MessageType.java
@@ -0,0 +1,23 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+
+enum MessageType {
+
+	INVITE(0), JOIN(1), LEAVE(2), ABORT(3);
+
+	private final int value;
+
+	MessageType(int value) {
+		this.value = value;
+	}
+
+	int getValue() {
+		return value;
+	}
+
+	static MessageType fromValue(int value) throws FormatException {
+		for (MessageType m : values()) if (m.value == value) return m;
+		throw new FormatException();
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/PeerProtocolEngine.java b/briar-core/src/org/briarproject/privategroup/invitation/PeerProtocolEngine.java
new file mode 100644
index 0000000000000000000000000000000000000000..6d3c721f38fea70a2a25c5396abc0e0f54bce442
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/PeerProtocolEngine.java
@@ -0,0 +1,324 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.clients.ProtocolStateException;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.privategroup.GroupMessageFactory;
+import org.briarproject.api.privategroup.PrivateGroupFactory;
+import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.sync.Message;
+import org.briarproject.api.system.Clock;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import static org.briarproject.privategroup.invitation.PeerState.AWAIT_MEMBER;
+import static org.briarproject.privategroup.invitation.PeerState.BOTH_JOINED;
+import static org.briarproject.privategroup.invitation.PeerState.ERROR;
+import static org.briarproject.privategroup.invitation.PeerState.LOCAL_JOINED;
+import static org.briarproject.privategroup.invitation.PeerState.LOCAL_LEFT;
+import static org.briarproject.privategroup.invitation.PeerState.NEITHER_JOINED;
+import static org.briarproject.privategroup.invitation.PeerState.START;
+
+@Immutable
+@NotNullByDefault
+class PeerProtocolEngine extends AbstractProtocolEngine<PeerSession> {
+
+	PeerProtocolEngine(DatabaseComponent db, ClientHelper clientHelper,
+			PrivateGroupManager privateGroupManager,
+			PrivateGroupFactory privateGroupFactory,
+			GroupMessageFactory groupMessageFactory,
+			IdentityManager identityManager, MessageParser messageParser,
+			MessageEncoder messageEncoder, Clock clock) {
+		super(db, clientHelper, privateGroupManager, privateGroupFactory,
+				groupMessageFactory, identityManager, messageParser,
+				messageEncoder, clock);
+	}
+
+	@Override
+	public PeerSession onInviteAction(Transaction txn, PeerSession s,
+			@Nullable String message, long timestamp, byte[] signature)
+			throws DbException {
+		throw new UnsupportedOperationException(); // Invalid in this role
+	}
+
+	@Override
+	public PeerSession onJoinAction(Transaction txn, PeerSession s)
+			throws DbException {
+		switch (s.getState()) {
+			case START:
+			case AWAIT_MEMBER:
+			case LOCAL_JOINED:
+			case BOTH_JOINED:
+			case ERROR:
+				throw new ProtocolStateException(); // Invalid in these states
+			case NEITHER_JOINED:
+				return onLocalJoinFromNeitherJoined(txn, s);
+			case LOCAL_LEFT:
+				return onLocalJoinFromLocalLeft(txn, s);
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public PeerSession onLeaveAction(Transaction txn, PeerSession s)
+			throws DbException {
+		switch (s.getState()) {
+			case START:
+			case AWAIT_MEMBER:
+			case NEITHER_JOINED:
+			case LOCAL_LEFT:
+			case ERROR:
+				return s; // Ignored in these states
+			case LOCAL_JOINED:
+				return onLocalLeaveFromLocalJoined(txn, s);
+			case BOTH_JOINED:
+				return onLocalLeaveFromBothJoined(txn, s);
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public PeerSession onMemberAddedAction(Transaction txn, PeerSession s)
+			throws DbException {
+		switch (s.getState()) {
+			case START:
+				return onMemberAddedFromStart(s);
+			case AWAIT_MEMBER:
+				return onMemberAddedFromAwaitMember(txn, s);
+			case NEITHER_JOINED:
+			case LOCAL_JOINED:
+			case BOTH_JOINED:
+			case LOCAL_LEFT:
+				throw new ProtocolStateException(); // Invalid in these states
+			case ERROR:
+				return s; // Ignored in this state
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public PeerSession onInviteMessage(Transaction txn, PeerSession s,
+			InviteMessage m) throws DbException, FormatException {
+		return abort(txn, s); // Invalid in this role
+	}
+
+	@Override
+	public PeerSession onJoinMessage(Transaction txn, PeerSession s,
+			JoinMessage m) throws DbException, FormatException {
+		switch (s.getState()) {
+			case AWAIT_MEMBER:
+			case BOTH_JOINED:
+			case LOCAL_LEFT:
+				return abort(txn, s); // Invalid in these states
+			case START:
+				return onRemoteJoinFromStart(txn, s, m);
+			case NEITHER_JOINED:
+				return onRemoteJoinFromNeitherJoined(txn, s, m);
+			case LOCAL_JOINED:
+				return onRemoteJoinFromLocalJoined(txn, s, m);
+			case ERROR:
+				return s; // Ignored in this state
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public PeerSession onLeaveMessage(Transaction txn, PeerSession s,
+			LeaveMessage m) throws DbException, FormatException {
+		switch (s.getState()) {
+			case START:
+			case NEITHER_JOINED:
+			case LOCAL_JOINED:
+				return abort(txn, s); // Invalid in these states
+			case AWAIT_MEMBER:
+				return onRemoteLeaveFromAwaitMember(txn, s, m);
+			case LOCAL_LEFT:
+				return onRemoteLeaveFromLocalLeft(txn, s, m);
+			case BOTH_JOINED:
+				return onRemoteLeaveFromBothJoined(txn, s, m);
+			case ERROR:
+				return s; // Ignored in this state
+			default:
+				throw new AssertionError();
+		}
+	}
+
+	@Override
+	public PeerSession onAbortMessage(Transaction txn, PeerSession s,
+			AbortMessage m) throws DbException, FormatException {
+		return abort(txn, s);
+	}
+
+	private PeerSession onLocalJoinFromNeitherJoined(Transaction txn,
+			PeerSession s) throws DbException {
+		// Send a JOIN message
+		Message sent = sendJoinMessage(txn, s, false);
+		// Move to the LOCAL_JOINED state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				LOCAL_JOINED);
+	}
+
+	private PeerSession onLocalJoinFromLocalLeft(Transaction txn, PeerSession s)
+			throws DbException {
+		// Send a JOIN message
+		Message sent = sendJoinMessage(txn, s, false);
+		try {
+			// Start syncing the private group with the contact
+			syncPrivateGroupWithContact(txn, s, true);
+		} catch (FormatException e) {
+			throw new DbException(e); // Invalid group metadata
+		}
+		// Move to the BOTH_JOINED state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				BOTH_JOINED);
+	}
+
+	private PeerSession onLocalLeaveFromBothJoined(Transaction txn,
+			PeerSession s) throws DbException {
+		// Send a LEAVE message
+		Message sent = sendLeaveMessage(txn, s, false);
+		try {
+			// Stop syncing the private group with the contact
+			syncPrivateGroupWithContact(txn, s, false);
+		} catch (FormatException e) {
+			throw new DbException(e); // Invalid group metadata
+		}
+		// Move to the LOCAL_LEFT state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				LOCAL_LEFT);
+	}
+
+	private PeerSession onLocalLeaveFromLocalJoined(Transaction txn,
+			PeerSession s) throws DbException {
+		// Send a LEAVE message
+		Message sent = sendLeaveMessage(txn, s, false);
+		// Move to the NEITHER_JOINED state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				NEITHER_JOINED);
+	}
+
+	private PeerSession onMemberAddedFromStart(PeerSession s) {
+		// Move to the NEITHER_JOINED state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), s.getLastRemoteMessageId(),
+				s.getLocalTimestamp(), NEITHER_JOINED);
+	}
+
+	private PeerSession onMemberAddedFromAwaitMember(Transaction txn,
+			PeerSession s) throws DbException {
+		// Send a JOIN message
+		Message sent = sendJoinMessage(txn, s, false);
+		try {
+			// Start syncing the private group with the contact
+			syncPrivateGroupWithContact(txn, s, true);
+		} catch (FormatException e) {
+			throw new DbException(e); // Invalid group metadata
+		}
+		// Move to the BOTH_JOINED state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				BOTH_JOINED);
+	}
+
+	private PeerSession onRemoteJoinFromStart(Transaction txn,
+			PeerSession s, JoinMessage m) throws DbException, FormatException {
+		// The dependency, if any, must be the last remote message
+		if (!isValidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+		// Move to the AWAIT_MEMBER state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
+				AWAIT_MEMBER);
+	}
+
+	private PeerSession onRemoteJoinFromNeitherJoined(Transaction txn,
+			PeerSession s, JoinMessage m) throws DbException, FormatException {
+		// The dependency, if any, must be the last remote message
+		if (!isValidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+		// Send a JOIN message
+		Message sent = sendJoinMessage(txn, s, false);
+		// Start syncing the private group with the contact
+		syncPrivateGroupWithContact(txn, s, true);
+		// Move to the BOTH_JOINED state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), m.getId(), sent.getTimestamp(), BOTH_JOINED);
+	}
+
+	private PeerSession onRemoteJoinFromLocalJoined(Transaction txn,
+			PeerSession s, JoinMessage m) throws DbException, FormatException {
+		// The dependency, if any, must be the last remote message
+		if (!isValidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+		// Start syncing the private group with the contact
+		syncPrivateGroupWithContact(txn, s, true);
+		// Move to the BOTH_JOINED state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
+				BOTH_JOINED);
+	}
+
+	private PeerSession onRemoteLeaveFromAwaitMember(Transaction txn,
+			PeerSession s, LeaveMessage m) throws DbException, FormatException {
+		// The dependency, if any, must be the last remote message
+		if (!isValidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+		// Move to the START state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
+				START);
+	}
+
+	private PeerSession onRemoteLeaveFromLocalLeft(Transaction txn,
+			PeerSession s, LeaveMessage m) throws DbException, FormatException {
+		// The dependency, if any, must be the last remote message
+		if (!isValidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+		// Move to the NEITHER_JOINED state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
+				NEITHER_JOINED);
+	}
+
+	private PeerSession onRemoteLeaveFromBothJoined(Transaction txn,
+			PeerSession s, LeaveMessage m) throws DbException, FormatException {
+		// The dependency, if any, must be the last remote message
+		if (!isValidDependency(s, m.getPreviousMessageId()))
+			return abort(txn, s);
+		// Stop syncing the private group with the contact
+		syncPrivateGroupWithContact(txn, s, false);
+		// Move to the LOCAL_JOINED state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				s.getLastLocalMessageId(), m.getId(), s.getLocalTimestamp(),
+				LOCAL_JOINED);
+	}
+
+	private PeerSession abort(Transaction txn, PeerSession s)
+			throws DbException, FormatException {
+		// If the session has already been aborted, do nothing
+		if (s.getState() == ERROR) return s;
+		// Stop syncing the private group with the contact, if we subscribe
+		if (isSubscribedPrivateGroup(txn, s.getPrivateGroupId()))
+			syncPrivateGroupWithContact(txn, s, false);
+		// Send an ABORT message
+		Message sent = sendAbortMessage(txn, s);
+		// Move to the ERROR state
+		return new PeerSession(s.getContactGroupId(), s.getPrivateGroupId(),
+				sent.getId(), s.getLastRemoteMessageId(), sent.getTimestamp(),
+				ERROR);
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/PeerSession.java b/briar-core/src/org/briarproject/privategroup/invitation/PeerSession.java
new file mode 100644
index 0000000000000000000000000000000000000000..2a6517dde443b9964791c51d24e6dd1b1205923e
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/PeerSession.java
@@ -0,0 +1,41 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import static org.briarproject.privategroup.invitation.PeerState.START;
+import static org.briarproject.privategroup.invitation.Role.PEER;
+
+@Immutable
+@NotNullByDefault
+class PeerSession extends Session<PeerState> {
+
+	private final PeerState state;
+
+	PeerSession(GroupId contactGroupId, GroupId privateGroupId,
+			@Nullable MessageId lastLocalMessageId,
+			@Nullable MessageId lastRemoteMessageId, long localTimestamp,
+			PeerState state) {
+		super(contactGroupId, privateGroupId, lastLocalMessageId,
+				lastRemoteMessageId, localTimestamp, 0);
+		this.state = state;
+	}
+
+	PeerSession(GroupId contactGroupId, GroupId privateGroupId) {
+		this(contactGroupId, privateGroupId, null, null, 0, START);
+	}
+
+	@Override
+	Role getRole() {
+		return PEER;
+	}
+
+	@Override
+	PeerState getState() {
+		return state;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/PeerState.java b/briar-core/src/org/briarproject/privategroup/invitation/PeerState.java
new file mode 100644
index 0000000000000000000000000000000000000000..eda52e5de71c34a11e572f0ec36bc9eaa0b3afc8
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/PeerState.java
@@ -0,0 +1,25 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+
+enum PeerState implements State {
+
+	START(0), AWAIT_MEMBER(1), NEITHER_JOINED(2), LOCAL_JOINED(3),
+	BOTH_JOINED(4), LOCAL_LEFT(5), ERROR(6);
+
+	private final int value;
+
+	PeerState(int value) {
+		this.value = value;
+	}
+
+	@Override
+	public int getValue() {
+		return value;
+	}
+
+	static PeerState fromValue(int value) throws FormatException {
+		for (PeerState s : values()) if (s.value == value) return s;
+		throw new FormatException();
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/ProtocolEngine.java b/briar-core/src/org/briarproject/privategroup/invitation/ProtocolEngine.java
new file mode 100644
index 0000000000000000000000000000000000000000..1908548f54b333219b5b39b35f51204414c99e16
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/ProtocolEngine.java
@@ -0,0 +1,34 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.db.Transaction;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+
+import javax.annotation.Nullable;
+
+@NotNullByDefault
+interface ProtocolEngine<S extends Session> {
+
+	S onInviteAction(Transaction txn, S session, @Nullable String message,
+			long timestamp, byte[] signature) throws DbException;
+
+	S onJoinAction(Transaction txn, S session) throws DbException;
+
+	S onLeaveAction(Transaction txn, S session) throws DbException;
+
+	S onMemberAddedAction(Transaction txn, S session) throws DbException;
+
+	S onInviteMessage(Transaction txn, S session, InviteMessage m)
+			throws DbException, FormatException;
+
+	S onJoinMessage(Transaction txn, S session, JoinMessage m)
+			throws DbException, FormatException;
+
+	S onLeaveMessage(Transaction txn, S session, LeaveMessage m)
+			throws DbException, FormatException;
+
+	S onAbortMessage(Transaction txn, S session, AbortMessage m)
+			throws DbException, FormatException;
+
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/ProtocolEngineFactory.java b/briar-core/src/org/briarproject/privategroup/invitation/ProtocolEngineFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..95e5882de72547ea387502be6c8664e2b3105189
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/ProtocolEngineFactory.java
@@ -0,0 +1,13 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.nullsafety.NotNullByDefault;
+
+@NotNullByDefault
+interface ProtocolEngineFactory {
+
+	ProtocolEngine<CreatorSession> createCreatorEngine();
+
+	ProtocolEngine<InviteeSession> createInviteeEngine();
+
+	ProtocolEngine<PeerSession> createPeerEngine();
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/ProtocolEngineFactoryImpl.java b/briar-core/src/org/briarproject/privategroup/invitation/ProtocolEngineFactoryImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..3c2c9ce84c25a46a9f98a4ae1eb259d7dc0d10e3
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/ProtocolEngineFactoryImpl.java
@@ -0,0 +1,68 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.clients.ClientHelper;
+import org.briarproject.api.db.DatabaseComponent;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.privategroup.GroupMessageFactory;
+import org.briarproject.api.privategroup.PrivateGroupFactory;
+import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.system.Clock;
+
+import javax.annotation.concurrent.Immutable;
+import javax.inject.Inject;
+
+@Immutable
+@NotNullByDefault
+class ProtocolEngineFactoryImpl implements ProtocolEngineFactory {
+
+	private final DatabaseComponent db;
+	private final ClientHelper clientHelper;
+	private final PrivateGroupManager privateGroupManager;
+	private final PrivateGroupFactory privateGroupFactory;
+	private final GroupMessageFactory groupMessageFactory;
+	private final IdentityManager identityManager;
+	private final MessageParser messageParser;
+	private final MessageEncoder messageEncoder;
+	private final Clock clock;
+
+	@Inject
+	ProtocolEngineFactoryImpl(DatabaseComponent db, ClientHelper clientHelper,
+			PrivateGroupManager privateGroupManager,
+			PrivateGroupFactory privateGroupFactory,
+			GroupMessageFactory groupMessageFactory,
+			IdentityManager identityManager, MessageParser messageParser,
+			MessageEncoder messageEncoder,
+			Clock clock) {
+		this.db = db;
+		this.clientHelper = clientHelper;
+		this.privateGroupManager = privateGroupManager;
+		this.privateGroupFactory = privateGroupFactory;
+		this.groupMessageFactory = groupMessageFactory;
+		this.identityManager = identityManager;
+		this.messageParser = messageParser;
+		this.messageEncoder = messageEncoder;
+		this.clock = clock;
+	}
+
+	@Override
+	public ProtocolEngine<CreatorSession> createCreatorEngine() {
+		return new CreatorProtocolEngine(db, clientHelper, privateGroupManager,
+				privateGroupFactory, groupMessageFactory, identityManager,
+				messageParser, messageEncoder, clock);
+	}
+
+	@Override
+	public ProtocolEngine<InviteeSession> createInviteeEngine() {
+		return new InviteeProtocolEngine(db, clientHelper, privateGroupManager,
+				privateGroupFactory, groupMessageFactory, identityManager,
+				messageParser, messageEncoder, clock);
+	}
+
+	@Override
+	public ProtocolEngine<PeerSession> createPeerEngine() {
+		return new PeerProtocolEngine(db, clientHelper, privateGroupManager,
+				privateGroupFactory, groupMessageFactory, identityManager,
+				messageParser, messageEncoder, clock);
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/Role.java b/briar-core/src/org/briarproject/privategroup/invitation/Role.java
new file mode 100644
index 0000000000000000000000000000000000000000..2e84f63b57250cbf424da0946ba91f7bb445de12
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/Role.java
@@ -0,0 +1,23 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+
+enum Role {
+
+	CREATOR(0), INVITEE(1), PEER(2);
+
+	private final int value;
+
+	Role(int value) {
+		this.value = value;
+	}
+
+	int getValue() {
+		return value;
+	}
+
+	static Role fromValue(int value) throws FormatException {
+		for (Role r : values()) if (r.value == value) return r;
+		throw new FormatException();
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/Session.java b/briar-core/src/org/briarproject/privategroup/invitation/Session.java
new file mode 100644
index 0000000000000000000000000000000000000000..1cd5c76889b96324a26ecac69e51da3c5816e944
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/Session.java
@@ -0,0 +1,60 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+abstract class Session<S extends State> {
+
+	private final GroupId contactGroupId, privateGroupId;
+	@Nullable
+	private final MessageId lastLocalMessageId, lastRemoteMessageId;
+	private final long localTimestamp, inviteTimestamp;
+
+	Session(GroupId contactGroupId, GroupId privateGroupId,
+			@Nullable MessageId lastLocalMessageId,
+			@Nullable MessageId lastRemoteMessageId, long localTimestamp,
+			long inviteTimestamp) {
+		this.contactGroupId = contactGroupId;
+		this.privateGroupId = privateGroupId;
+		this.lastLocalMessageId = lastLocalMessageId;
+		this.lastRemoteMessageId = lastRemoteMessageId;
+		this.localTimestamp = localTimestamp;
+		this.inviteTimestamp = inviteTimestamp;
+	}
+
+	abstract Role getRole();
+
+	abstract S getState();
+
+	GroupId getContactGroupId() {
+		return contactGroupId;
+	}
+
+	GroupId getPrivateGroupId() {
+		return privateGroupId;
+	}
+
+	@Nullable
+	MessageId getLastLocalMessageId() {
+		return lastLocalMessageId;
+	}
+
+	@Nullable
+	MessageId getLastRemoteMessageId() {
+		return lastRemoteMessageId;
+	}
+
+	long getLocalTimestamp() {
+		return localTimestamp;
+	}
+
+	long getInviteTimestamp() {
+		return inviteTimestamp;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/SessionEncoder.java b/briar-core/src/org/briarproject/privategroup/invitation/SessionEncoder.java
new file mode 100644
index 0000000000000000000000000000000000000000..5ba2cb48c5fc27ee54b56d5ac8aaac02db810cff
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/SessionEncoder.java
@@ -0,0 +1,10 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+
+@NotNullByDefault
+interface SessionEncoder {
+
+	BdfDictionary encodeSession(Session s);
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/SessionEncoderImpl.java b/briar-core/src/org/briarproject/privategroup/invitation/SessionEncoderImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..32f85534a58452e07b0cebdcf604ec5e47b77200
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/SessionEncoderImpl.java
@@ -0,0 +1,47 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.concurrent.Immutable;
+import javax.inject.Inject;
+
+import static org.briarproject.api.data.BdfDictionary.NULL_VALUE;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_INVITE_TIMESTAMP;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_LAST_LOCAL_MESSAGE_ID;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_LAST_REMOTE_MESSAGE_ID;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_LOCAL_TIMESTAMP;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_PRIVATE_GROUP_ID;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_ROLE;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_SESSION_ID;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_STATE;
+
+@Immutable
+@NotNullByDefault
+class SessionEncoderImpl implements SessionEncoder {
+
+	@Inject
+	SessionEncoderImpl() {
+	}
+
+	@Override
+	public BdfDictionary encodeSession(Session s) {
+		BdfDictionary d = new BdfDictionary();
+		d.put(SESSION_KEY_SESSION_ID, s.getPrivateGroupId());
+		d.put(SESSION_KEY_PRIVATE_GROUP_ID, s.getPrivateGroupId());
+		MessageId lastLocalMessageId = s.getLastLocalMessageId();
+		if (lastLocalMessageId == null)
+			d.put(SESSION_KEY_LAST_LOCAL_MESSAGE_ID, NULL_VALUE);
+		else d.put(SESSION_KEY_LAST_LOCAL_MESSAGE_ID, lastLocalMessageId);
+		MessageId lastRemoteMessageId = s.getLastRemoteMessageId();
+		if (lastRemoteMessageId == null)
+			d.put(SESSION_KEY_LAST_REMOTE_MESSAGE_ID, NULL_VALUE);
+		else d.put(SESSION_KEY_LAST_REMOTE_MESSAGE_ID, lastRemoteMessageId);
+		d.put(SESSION_KEY_LOCAL_TIMESTAMP, s.getLocalTimestamp());
+		d.put(SESSION_KEY_INVITE_TIMESTAMP, s.getInviteTimestamp());
+		d.put(SESSION_KEY_ROLE, s.getRole().getValue());
+		d.put(SESSION_KEY_STATE, s.getState().getValue());
+		return d;
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/SessionParser.java b/briar-core/src/org/briarproject/privategroup/invitation/SessionParser.java
new file mode 100644
index 0000000000000000000000000000000000000000..8f488298b0b327db3fb94e2a8ca87178ded9fb97
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/SessionParser.java
@@ -0,0 +1,24 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.SessionId;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+
+@NotNullByDefault
+interface SessionParser {
+
+	BdfDictionary getSessionQuery(SessionId s);
+
+	Role getRole(BdfDictionary d) throws FormatException;
+
+	CreatorSession parseCreatorSession(GroupId contactGroupId, BdfDictionary d)
+			throws FormatException;
+
+	InviteeSession parseInviteeSession(GroupId contactGroupId, BdfDictionary d)
+			throws FormatException;
+
+	PeerSession parsePeerSession(GroupId contactGroupId, BdfDictionary d)
+			throws FormatException;
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/SessionParserImpl.java b/briar-core/src/org/briarproject/privategroup/invitation/SessionParserImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..c8e468db39654bca7c41d0dc4b1112a59c6e75e7
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/SessionParserImpl.java
@@ -0,0 +1,103 @@
+package org.briarproject.privategroup.invitation;
+
+import org.briarproject.api.FormatException;
+import org.briarproject.api.clients.SessionId;
+import org.briarproject.api.data.BdfDictionary;
+import org.briarproject.api.data.BdfEntry;
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+import javax.inject.Inject;
+
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_INVITE_TIMESTAMP;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_LAST_LOCAL_MESSAGE_ID;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_LAST_REMOTE_MESSAGE_ID;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_LOCAL_TIMESTAMP;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_PRIVATE_GROUP_ID;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_ROLE;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_SESSION_ID;
+import static org.briarproject.privategroup.invitation.GroupInvitationConstants.SESSION_KEY_STATE;
+import static org.briarproject.privategroup.invitation.Role.CREATOR;
+import static org.briarproject.privategroup.invitation.Role.INVITEE;
+import static org.briarproject.privategroup.invitation.Role.PEER;
+
+@Immutable
+@NotNullByDefault
+class SessionParserImpl implements SessionParser {
+
+	@Inject
+	SessionParserImpl() {
+	}
+
+	@Override
+	public BdfDictionary getSessionQuery(SessionId s) {
+		return BdfDictionary.of(new BdfEntry(SESSION_KEY_SESSION_ID, s));
+	}
+
+	@Override
+	public Role getRole(BdfDictionary d) throws FormatException {
+		return Role.fromValue(d.getLong(SESSION_KEY_ROLE).intValue());
+	}
+
+	@Override
+	public CreatorSession parseCreatorSession(GroupId contactGroupId,
+			BdfDictionary d) throws FormatException {
+		if (getRole(d) != CREATOR) throw new IllegalArgumentException();
+		return new CreatorSession(contactGroupId, getPrivateGroupId(d),
+				getLastLocalMessageId(d), getLastRemoteMessageId(d),
+				getLocalTimestamp(d), getInviteTimestamp(d),
+				CreatorState.fromValue(getState(d)));
+	}
+
+	@Override
+	public InviteeSession parseInviteeSession(GroupId contactGroupId,
+			BdfDictionary d) throws FormatException {
+		if (getRole(d) != INVITEE) throw new IllegalArgumentException();
+		return new InviteeSession(contactGroupId, getPrivateGroupId(d),
+				getLastLocalMessageId(d), getLastRemoteMessageId(d),
+				getLocalTimestamp(d), getInviteTimestamp(d),
+				InviteeState.fromValue(getState(d)));
+	}
+
+	@Override
+	public PeerSession parsePeerSession(GroupId contactGroupId,
+			BdfDictionary d) throws FormatException {
+		if (getRole(d) != PEER) throw new IllegalArgumentException();
+		return new PeerSession(contactGroupId, getPrivateGroupId(d),
+				getLastLocalMessageId(d), getLastRemoteMessageId(d),
+				getLocalTimestamp(d), PeerState.fromValue(getState(d)));
+	}
+
+	private int getState(BdfDictionary d) throws FormatException {
+		return d.getLong(SESSION_KEY_STATE).intValue();
+	}
+
+	private GroupId getPrivateGroupId(BdfDictionary d) throws FormatException {
+		return new GroupId(d.getRaw(SESSION_KEY_PRIVATE_GROUP_ID));
+	}
+
+	@Nullable
+	private MessageId getLastLocalMessageId(BdfDictionary d)
+			throws FormatException {
+		byte[] b = d.getOptionalRaw(SESSION_KEY_LAST_LOCAL_MESSAGE_ID);
+		return b == null ? null : new MessageId(b);
+	}
+
+	@Nullable
+	private MessageId getLastRemoteMessageId(BdfDictionary d)
+			throws FormatException {
+		byte[] b = d.getOptionalRaw(SESSION_KEY_LAST_REMOTE_MESSAGE_ID);
+		return b == null ? null : new MessageId(b);
+	}
+
+	private long getLocalTimestamp(BdfDictionary d) throws FormatException {
+		return d.getLong(SESSION_KEY_LOCAL_TIMESTAMP);
+	}
+
+	private long getInviteTimestamp(BdfDictionary d) throws FormatException {
+		return d.getLong(SESSION_KEY_INVITE_TIMESTAMP);
+	}
+}
diff --git a/briar-core/src/org/briarproject/privategroup/invitation/State.java b/briar-core/src/org/briarproject/privategroup/invitation/State.java
new file mode 100644
index 0000000000000000000000000000000000000000..6c112668ac1ab2484cd10ea2106c8c64724ac2c7
--- /dev/null
+++ b/briar-core/src/org/briarproject/privategroup/invitation/State.java
@@ -0,0 +1,6 @@
+package org.briarproject.privategroup.invitation;
+
+interface State {
+
+	int getValue();
+}